""" 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"{text}", 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'\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)) # 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()