Spaces:
Running
Running
| from fastapi import APIRouter, Query, HTTPException | |
| from fastapi.responses import StreamingResponse | |
| from PIL import Image, ImageDraw, ImageFont | |
| from io import BytesIO | |
| import requests | |
| from typing import Optional, List | |
| router = APIRouter() | |
| def download_image_from_url(url: str) -> Image.Image: | |
| headers = { | |
| "User-Agent": ( | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | |
| "AppleWebKit/537.36 (KHTML, like Gecko) " | |
| "Chrome/115.0.0.0 Safari/537.36" | |
| ) | |
| } | |
| try: | |
| response = requests.get(url, headers=headers, timeout=10) | |
| response.raise_for_status() | |
| return Image.open(BytesIO(response.content)).convert("RGBA") | |
| except Exception as e: | |
| raise HTTPException(status_code=400, detail=f"Erro ao baixar imagem: {url} ({str(e)})") | |
| def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image: | |
| """ | |
| Redimensiona e corta a imagem para preencher exatamente o espaço alvo (sempre centralizado). | |
| Args: | |
| img: Imagem PIL | |
| target_width: Largura alvo | |
| target_height: Altura alvo | |
| """ | |
| img_ratio = img.width / img.height | |
| target_ratio = target_width / target_height | |
| if img_ratio > target_ratio: | |
| # Imagem é mais larga proporcionalmente - redimensionar baseado na altura | |
| scale_height = target_height | |
| scale_width = int(scale_height * img_ratio) | |
| else: | |
| # Imagem é mais alta proporcionalmente - redimensionar baseado na largura | |
| scale_width = target_width | |
| scale_height = int(scale_width / img_ratio) | |
| img_resized = img.resize((scale_width, scale_height), Image.LANCZOS) | |
| # Centralizar o crop | |
| left = (scale_width - target_width) // 2 | |
| top = (scale_height - target_height) // 2 | |
| right = left + target_width | |
| bottom = top + target_height | |
| return img_resized.crop((left, top, right, bottom)) | |
| def create_gradient_overlay(width: int, height: int, position: str = "bottom") -> Image.Image: | |
| """ Cria o gradiente overlay conforme especificado """ | |
| gradient = Image.new("RGBA", (width, height)) | |
| draw = ImageDraw.Draw(gradient) | |
| if position == "top": | |
| # Gradiente para topo: começa escuro em cima e vai ficando transparente | |
| # linear-gradient(180deg, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.55) 45.06%, rgba(0, 0, 0, 0) 100%) | |
| gradient_start = 0 | |
| gradient_height = 706 | |
| for y in range(gradient_height): | |
| if y < height: | |
| ratio = y / gradient_height | |
| if ratio <= 0.4506: # 0% a 45.06%: de rgba(0,0,0,0.7) para rgba(0,0,0,0.55) | |
| opacity_ratio = ratio / 0.4506 | |
| opacity = int(255 * (0.7 - 0.15 * opacity_ratio)) | |
| else: # 45.06% a 100%: de rgba(0,0,0,0.55) para transparente | |
| opacity_ratio = (ratio - 0.4506) / (1 - 0.4506) | |
| opacity = int(255 * 0.55 * (1 - opacity_ratio)) | |
| draw.line([(0, y), (width, y)], fill=(0, 0, 0, opacity)) | |
| else: | |
| # Gradiente para baixo: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.55) 54.94%, rgba(0, 0, 0, 0.7) 100%) | |
| # Posição: left: 0px, top: 644px, width: 1080px, height: 706px | |
| gradient_start = 644 | |
| gradient_height = 706 | |
| for y in range(gradient_height): | |
| if y + gradient_start < height: | |
| ratio = y / gradient_height | |
| if ratio <= 0.5494: # 0% a 54.94%: de transparente para rgba(0,0,0,0.55) | |
| opacity_ratio = ratio / 0.5494 | |
| opacity = int(255 * 0.55 * opacity_ratio) | |
| else: # 54.94% a 100%: de rgba(0,0,0,0.55) para rgba(0,0,0,0.7) | |
| opacity_ratio = (ratio - 0.5494) / (1 - 0.5494) | |
| opacity = int(255 * (0.55 + 0.15 * opacity_ratio)) | |
| draw.line([(0, y + gradient_start), (width, y + gradient_start)], fill=(0, 0, 0, opacity)) | |
| return gradient | |
| def render_text(draw: ImageDraw.Draw, text: str, x: int, y: int, max_width: int, max_lines: int = 5, align_top: bool = False, text_color: tuple = (255, 255, 255, 255)) -> None: | |
| """ | |
| Renderiza texto com fonte Montserrat Regular, suportando formatação com asteriscos para negrito. | |
| Ajusta automaticamente o tamanho da fonte para caber em max_lines. | |
| Args: | |
| draw: Objeto ImageDraw para desenhar | |
| text: Texto a ser renderizado (suporta *texto* para negrito) | |
| x: Posição X (esquerda) | |
| y: Posição Y (topo do texto se align_top=True, base se align_top=False) | |
| max_width: Largura máxima do texto | |
| max_lines: Número máximo de linhas (padrão: 5) | |
| align_top: Se True, alinha pelo topo; se False, alinha pela base | |
| text_color: Cor do texto como tuple (R, G, B, A) | |
| """ | |
| import re | |
| if not text.strip(): | |
| return | |
| # Carregar fontes | |
| try: | |
| regular_font_path = "fonts/Montserrat-Regular.ttf" | |
| bold_font_path = "fonts/Montserrat-Bold.ttf" | |
| except (OSError, IOError): | |
| regular_font_path = None | |
| bold_font_path = None | |
| # Função para processar texto com formatação | |
| def parse_formatted_text(text): | |
| """Processa texto e retorna lista de tokens (palavras/espaços/pontuação) com formatação""" | |
| tokens = [] | |
| current_token = "" | |
| in_bold = False | |
| i = 0 | |
| while i < len(text): | |
| if text[i] == '*': | |
| # Salvar token atual se não estiver vazio | |
| if current_token: | |
| tokens.append((current_token, in_bold)) | |
| current_token = "" | |
| # Alternar negrito | |
| in_bold = not in_bold | |
| i += 1 | |
| else: | |
| current_token += text[i] | |
| i += 1 | |
| # Adicionar último token | |
| if current_token: | |
| tokens.append((current_token, in_bold)) | |
| return tokens | |
| # Função para tokenizar respeitando espaços e pontuação | |
| def tokenize_with_formatting(tokens): | |
| """Quebra tokens em palavras individuais, espaços e pontuação mantendo formatação""" | |
| result = [] | |
| for token_text, is_bold in tokens: | |
| # Usar regex para dividir em palavras, espaços e pontuação | |
| parts = re.findall(r'\S+|\s+', token_text) | |
| for part in parts: | |
| result.append((part, is_bold)) | |
| return result | |
| # Processar texto com formatação | |
| formatted_tokens = parse_formatted_text(text) | |
| detailed_tokens = tokenize_with_formatting(formatted_tokens) | |
| # Função para quebrar texto em linhas considerando formatação | |
| def wrap_formatted_text(tokens, max_width, font_size): | |
| lines = [] | |
| current_line = [] | |
| # Carregar fontes com tamanho específico | |
| try: | |
| regular_font_size = ImageFont.truetype(regular_font_path, font_size) if regular_font_path else ImageFont.load_default() | |
| bold_font_size = ImageFont.truetype(bold_font_path, font_size) if bold_font_path else ImageFont.load_default() | |
| except (OSError, IOError): | |
| regular_font_size = ImageFont.load_default() | |
| bold_font_size = ImageFont.load_default() | |
| i = 0 | |
| while i < len(tokens): | |
| token_text, is_bold = tokens[i] | |
| # Se for espaço em branco, pular para próximo token | |
| if token_text.isspace(): | |
| i += 1 | |
| continue | |
| # Testar se o token cabe na linha atual | |
| test_line = current_line + [(token_text, is_bold)] | |
| # Calcular largura da linha de teste | |
| test_width = 0 | |
| for j, (seg_text, seg_is_bold) in enumerate(test_line): | |
| # Usar fonte com tamanho correto | |
| font_to_use = bold_font_size if seg_is_bold else regular_font_size | |
| bbox = font_to_use.getbbox(seg_text) | |
| test_width += bbox[2] - bbox[0] | |
| # Adicionar espaço entre palavras (apenas se não for pontuação) | |
| if j < len(test_line) - 1: | |
| next_text = test_line[j + 1][0] | |
| # Não adicionar espaço se o próximo token for pontuação | |
| if not re.match(r'^[^\w\s]+$', next_text): | |
| space_bbox = font_to_use.getbbox(" ") | |
| test_width += space_bbox[2] - space_bbox[0] | |
| if test_width <= max_width: | |
| current_line.append((token_text, is_bold)) | |
| else: | |
| # Quebrar linha | |
| if current_line: | |
| lines.append(current_line) | |
| current_line = [(token_text, is_bold)] | |
| else: | |
| # Token muito longo, adiciona mesmo assim | |
| lines.append([(token_text, is_bold)]) | |
| i += 1 | |
| if current_line: | |
| lines.append(current_line) | |
| return lines | |
| # Encontrar o tamanho de fonte ideal | |
| font_size = 32 # Tamanho inicial | |
| min_font_size = 12 # Tamanho mínimo | |
| # Tentar diferentes tamanhos de fonte até encontrar um que caiba em max_lines | |
| while font_size >= min_font_size: | |
| lines = wrap_formatted_text(detailed_tokens, max_width, font_size) | |
| # Se o texto cabe em max_lines ou menos, usar este tamanho | |
| if len(lines) <= max_lines: | |
| break | |
| # Reduzir o tamanho da fonte | |
| font_size -= 2 | |
| # Garantir que não seja menor que o tamanho mínimo | |
| font_size = max(font_size, min_font_size) | |
| # Carregar fontes finais | |
| try: | |
| final_regular_font = ImageFont.truetype(regular_font_path, font_size) if regular_font_path else ImageFont.load_default() | |
| final_bold_font = ImageFont.truetype(bold_font_path, font_size) if bold_font_path else ImageFont.load_default() | |
| except (OSError, IOError): | |
| final_regular_font = ImageFont.load_default() | |
| final_bold_font = ImageFont.load_default() | |
| # Quebrar texto final | |
| lines = wrap_formatted_text(detailed_tokens, max_width, font_size) | |
| # Se ainda não couber em max_lines, forçar quebra nas primeiras max_lines | |
| if len(lines) > max_lines: | |
| # Combinar as linhas restantes na última linha permitida | |
| combined_tokens = [] | |
| for i, line in enumerate(lines): | |
| if i < max_lines - 1: | |
| combined_tokens.extend(line) | |
| elif i == max_lines - 1: | |
| # Última linha permitida - combinar com todas as linhas restantes | |
| remaining_tokens = [] | |
| for j in range(i, len(lines)): | |
| remaining_tokens.extend(lines[j]) | |
| combined_tokens.extend(remaining_tokens) | |
| break | |
| lines = wrap_formatted_text(combined_tokens, max_width, font_size) | |
| lines = lines[:max_lines] # Garantir que não exceda max_lines | |
| # Desenhar texto baseado no alinhamento | |
| if align_top: | |
| # Alinhamento pelo topo: desenhar de cima para baixo | |
| current_y = y | |
| for line in lines: | |
| # Calcular altura da linha (usar altura máxima dos segmentos) | |
| max_line_height = 0 | |
| for segment_text, is_bold in line: | |
| font_to_use = final_bold_font if is_bold else final_regular_font | |
| bbox = font_to_use.getbbox(segment_text) | |
| line_height = bbox[3] - bbox[1] | |
| max_line_height = max(max_line_height, line_height) | |
| # Desenhar segmentos da linha | |
| current_x = x | |
| for i, (segment_text, is_bold) in enumerate(line): | |
| font_to_use = final_bold_font if is_bold else final_regular_font | |
| draw.text((current_x, current_y), segment_text, fill=text_color, font=font_to_use) | |
| bbox = font_to_use.getbbox(segment_text) | |
| current_x += bbox[2] - bbox[0] | |
| # Adicionar espaço entre palavras (apenas se não for pontuação) | |
| if i < len(line) - 1: | |
| next_text = line[i + 1][0] | |
| # Não adicionar espaço se o próximo token for pontuação | |
| if not re.match(r'^[^\w\s]+$', next_text): | |
| space_bbox = font_to_use.getbbox(" ") | |
| current_x += space_bbox[2] - space_bbox[0] | |
| # Aplicar line height de 120% (adicionar 20% de espaçamento extra) | |
| current_y += int(max_line_height * 1.20) | |
| else: | |
| # Alinhamento pela base: desenhar de baixo para cima | |
| current_y = y | |
| for line in reversed(lines): | |
| # Calcular altura da linha (usar altura máxima dos segmentos) | |
| max_line_height = 0 | |
| for segment_text, is_bold in line: | |
| font_to_use = final_bold_font if is_bold else final_regular_font | |
| bbox = font_to_use.getbbox(segment_text) | |
| line_height = bbox[3] - bbox[1] | |
| max_line_height = max(max_line_height, line_height) | |
| # Aplicar line height de 120% (adicionar 20% de espaçamento extra) | |
| current_y -= int(max_line_height * 1.20) | |
| # Desenhar segmentos da linha | |
| current_x = x | |
| for i, (segment_text, is_bold) in enumerate(line): | |
| font_to_use = final_bold_font if is_bold else final_regular_font | |
| draw.text((current_x, current_y), segment_text, fill=text_color, font=font_to_use) | |
| bbox = font_to_use.getbbox(segment_text) | |
| current_x += bbox[2] - bbox[0] | |
| # Adicionar espaço entre palavras (apenas se não for pontuação) | |
| if i < len(line) - 1: | |
| next_text = line[i + 1][0] | |
| # Não adicionar espaço se o próximo token for pontuação | |
| if not re.match(r'^[^\w\s]+$', next_text): | |
| space_bbox = font_to_use.getbbox(" ") | |
| current_x += space_bbox[2] - space_bbox[0] | |
| def calculate_text_height(text: str, font, max_width: int) -> int: | |
| """Calcula a altura total do texto quebrado em linhas""" | |
| if not text.strip(): | |
| return 0 | |
| words = text.split() | |
| if not words: | |
| return 0 | |
| # Quebrar texto em linhas | |
| lines = [] | |
| current_line = [] | |
| for word in words: | |
| test_line = " ".join(current_line + [word]) | |
| bbox = font.getbbox(test_line) | |
| line_width = bbox[2] - bbox[0] | |
| if line_width <= max_width: | |
| current_line.append(word) | |
| else: | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| current_line = [word] | |
| else: | |
| lines.append(word) | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| # Calcular altura total | |
| total_height = 0 | |
| for line in lines: | |
| bbox = font.getbbox(line) | |
| line_height = bbox[3] - bbox[1] | |
| total_height += int(line_height * 1.20) # Aplicar line height de 120% | |
| return total_height | |
| def render_slide_text(draw: ImageDraw.Draw, text: str, x: int, y: int, max_width: int, max_lines: int = 5, center_vertical: bool = False, text_color: tuple = (0, 0, 0, 255)) -> None: | |
| """ | |
| Renderiza texto para slides com fonte padrão, tamanho inicial 32px. | |
| Suporta formatação com asteriscos para negrito. | |
| Args: | |
| draw: Objeto ImageDraw para desenhar | |
| text: Texto a ser renderizado (suporta *texto* para negrito) | |
| x: Posição X (esquerda) | |
| y: Posição Y (topo do texto se center_vertical=False, centro se center_vertical=True) | |
| max_width: Largura máxima do texto | |
| max_lines: Número máximo de linhas (padrão: 5) | |
| center_vertical: Se True, centraliza verticalmente o texto na posição Y | |
| text_color: Cor do texto como tuple (R, G, B, A) | |
| """ | |
| import re | |
| if not text.strip(): | |
| return | |
| # Carregar fontes | |
| try: | |
| regular_font_path = "fonts/Montserrat-Regular.ttf" | |
| bold_font_path = "fonts/Montserrat-Bold.ttf" | |
| regular_font = ImageFont.truetype(regular_font_path, 32) | |
| bold_font = ImageFont.truetype(bold_font_path, 32) | |
| except (OSError, IOError): | |
| # Fallback para fonte padrão se as fontes personalizadas não forem encontradas | |
| regular_font = ImageFont.load_default() | |
| bold_font = ImageFont.load_default() | |
| # Função para processar texto com formatação | |
| def parse_formatted_text(text): | |
| """Processa texto e retorna lista de tokens (palavras/espaços/pontuação) com formatação""" | |
| tokens = [] | |
| current_token = "" | |
| in_bold = False | |
| current_font = regular_font | |
| i = 0 | |
| while i < len(text): | |
| if text[i] == '*': | |
| # Salvar token atual se não estiver vazio | |
| if current_token: | |
| tokens.append((current_token, current_font)) | |
| current_token = "" | |
| # Alternar negrito | |
| in_bold = not in_bold | |
| current_font = bold_font if in_bold else regular_font | |
| i += 1 | |
| else: | |
| current_token += text[i] | |
| i += 1 | |
| # Adicionar último token | |
| if current_token: | |
| tokens.append((current_token, current_font)) | |
| return tokens | |
| # Função para tokenizar respeitando espaços e pontuação | |
| def tokenize_with_formatting(tokens): | |
| """Quebra tokens em palavras individuais, espaços e pontuação mantendo formatação""" | |
| result = [] | |
| for token_text, font in tokens: | |
| # Usar regex para dividir em palavras, espaços e pontuação | |
| parts = re.findall(r'\S+|\s+', token_text) | |
| for part in parts: | |
| result.append((part, font)) | |
| return result | |
| # Processar texto com formatação | |
| formatted_tokens = parse_formatted_text(text) | |
| detailed_tokens = tokenize_with_formatting(formatted_tokens) | |
| # Função para quebrar texto em linhas considerando formatação | |
| def wrap_formatted_text(tokens, max_width, font_size): | |
| lines = [] | |
| current_line = [] | |
| # Carregar fontes com tamanho específico | |
| try: | |
| regular_font_size = ImageFont.truetype(regular_font_path, font_size) | |
| bold_font_size = ImageFont.truetype(bold_font_path, font_size) | |
| except (OSError, IOError): | |
| regular_font_size = ImageFont.load_default() | |
| bold_font_size = ImageFont.load_default() | |
| i = 0 | |
| while i < len(tokens): | |
| token_text, token_font = tokens[i] | |
| # Se for espaço em branco, pular para próximo token | |
| if token_text.isspace(): | |
| i += 1 | |
| continue | |
| # Testar se o token cabe na linha atual | |
| test_line = current_line + [(token_text, token_font)] | |
| # Calcular largura da linha de teste | |
| test_width = 0 | |
| for j, (seg_text, seg_font) in enumerate(test_line): | |
| # Usar fonte com tamanho correto | |
| font_to_use = regular_font_size if seg_font == regular_font else bold_font_size | |
| bbox = font_to_use.getbbox(seg_text) | |
| test_width += bbox[2] - bbox[0] | |
| # Adicionar espaço entre palavras (apenas se não for pontuação) | |
| if j < len(test_line) - 1: | |
| next_text = test_line[j + 1][0] | |
| # Não adicionar espaço se o próximo token for pontuação | |
| if not re.match(r'^[^\w\s]+$', next_text): | |
| space_bbox = font_to_use.getbbox(" ") | |
| test_width += space_bbox[2] - space_bbox[0] | |
| if test_width <= max_width: | |
| current_line.append((token_text, token_font)) | |
| else: | |
| # Quebrar linha | |
| if current_line: | |
| lines.append(current_line) | |
| current_line = [(token_text, token_font)] | |
| else: | |
| # Token muito longo, adiciona mesmo assim | |
| lines.append([(token_text, token_font)]) | |
| i += 1 | |
| if current_line: | |
| lines.append(current_line) | |
| return lines | |
| # Encontrar o tamanho de fonte ideal | |
| font_size = 32 # Tamanho inicial | |
| min_font_size = 12 # Tamanho mínimo | |
| # Tentar diferentes tamanhos de fonte até encontrar um que caiba em max_lines | |
| while font_size >= min_font_size: | |
| lines = wrap_formatted_text(detailed_tokens, max_width, font_size) | |
| # Se o texto cabe em max_lines ou menos, usar este tamanho | |
| if len(lines) <= max_lines: | |
| break | |
| # Reduzir o tamanho da fonte | |
| font_size -= 2 | |
| # Garantir que não seja menor que o tamanho mínimo | |
| font_size = max(font_size, min_font_size) | |
| # Carregar fontes finais | |
| try: | |
| final_regular_font = ImageFont.truetype(regular_font_path, font_size) | |
| final_bold_font = ImageFont.truetype(bold_font_path, font_size) | |
| except (OSError, IOError): | |
| final_regular_font = ImageFont.load_default() | |
| final_bold_font = ImageFont.load_default() | |
| # Quebrar texto final | |
| lines = wrap_formatted_text(detailed_tokens, max_width, font_size) | |
| # Se ainda não couber em max_lines, forçar quebra nas primeiras max_lines | |
| if len(lines) > max_lines: | |
| # Combinar as linhas restantes na última linha permitida | |
| combined_tokens = [] | |
| for i, line in enumerate(lines): | |
| if i < max_lines - 1: | |
| combined_tokens.extend(line) | |
| elif i == max_lines - 1: | |
| # Última linha permitida - combinar com todas as linhas restantes | |
| remaining_tokens = [] | |
| for j in range(i, len(lines)): | |
| remaining_tokens.extend(lines[j]) | |
| combined_tokens.extend(remaining_tokens) | |
| break | |
| lines = wrap_formatted_text(combined_tokens, max_width, font_size) | |
| lines = lines[:max_lines] # Garantir que não exceda max_lines | |
| # Calcular altura total do texto para centralização vertical | |
| total_text_height = 0 | |
| line_heights = [] | |
| for line in lines: | |
| max_line_height = 0 | |
| for segment_text, segment_font in line: | |
| font_to_use = final_regular_font if segment_font == regular_font else final_bold_font | |
| bbox = font_to_use.getbbox(segment_text) | |
| line_height = bbox[3] - bbox[1] | |
| max_line_height = max(max_line_height, line_height) | |
| line_heights.append(max_line_height) | |
| total_text_height += int(max_line_height * 1.20) # Aplicar line height de 120% | |
| # Ajustar posição Y baseado na centralização | |
| if center_vertical: | |
| # Y representa o centro vertical do texto, então subtraímos metade da altura total | |
| start_y = y - (total_text_height // 2) | |
| else: | |
| # Y representa o topo do texto | |
| start_y = y | |
| # Desenhar texto de cima para baixo | |
| current_y = start_y | |
| for line_idx, line in enumerate(lines): | |
| # Calcular altura da linha (usar altura máxima dos segmentos) | |
| max_line_height = line_heights[line_idx] | |
| # Desenhar segmentos da linha | |
| current_x = x | |
| for i, (segment_text, segment_font) in enumerate(line): | |
| font_to_use = final_regular_font if segment_font == regular_font else final_bold_font | |
| draw.text((current_x, current_y), segment_text, fill=text_color, font=font_to_use) | |
| bbox = font_to_use.getbbox(segment_text) | |
| current_x += bbox[2] - bbox[0] | |
| # Adicionar espaço entre palavras (apenas se não for pontuação) | |
| if i < len(line) - 1: | |
| next_text = line[i + 1][0] | |
| # Não adicionar espaço se o próximo token for pontuação | |
| if not re.match(r'^[^\w\s]+$', next_text): | |
| space_bbox = font_to_use.getbbox(" ") | |
| current_x += space_bbox[2] - space_bbox[0] | |
| # Aplicar line height de 120% (adicionar 20% de espaçamento extra) | |
| current_y += int(max_line_height * 1.20) | |
| def get_slide_color(slide_color: int) -> tuple: | |
| """ | |
| Retorna a cor do slide baseada no número fornecido. | |
| Args: | |
| slide_color: Número da cor (1-8) | |
| Returns: | |
| Tuple (R, G, B, A) da cor | |
| """ | |
| color_map = { | |
| 1: (0, 0, 0, 255), # #000000 - Preto | |
| 2: (191, 198, 147, 255), # #BFC693 | |
| 3: (246, 164, 29, 255), # #f6a41d | |
| 4: (245, 244, 239, 255), # #f5f4ef | |
| 5: (111, 180, 165, 255), # #6fb4a5 | |
| 6: (25, 83, 208, 255), # #1953d0 | |
| 7: (255, 80, 17, 255), # #ff5011 | |
| 8: (255, 217, 194, 255), # #ffd9c2 | |
| } | |
| # Se o número for inválido, usar preto como padrão | |
| return color_map.get(slide_color, (0, 0, 0, 255)) | |
| def get_text_color(slide_color: int) -> tuple: | |
| """ | |
| Retorna a cor do texto baseada na cor do slide. | |
| Args: | |
| slide_color: Número da cor do slide (1-8) | |
| Returns: | |
| Tuple (R, G, B, A) da cor do texto | |
| """ | |
| # Cores que devem ter texto branco: 1 (preto), 6 (azul), e cores inválidas (padrão preto) | |
| if slide_color == 1 or slide_color == 6 or slide_color not in [1, 2, 3, 4, 5, 6, 7, 8]: | |
| return (255, 255, 255, 255) # Branco | |
| else: | |
| return (0, 0, 0, 255) # Preto | |
| def get_logo_path(slide_color: int) -> str: | |
| """ | |
| Retorna o caminho da logo baseado na cor do slide. | |
| Args: | |
| slide_color: Número da cor do slide (1-8) | |
| Returns: | |
| Caminho da logo | |
| """ | |
| # Cores que devem usar logo branca: 1 (preto), 6 (azul), e cores inválidas (padrão preto) | |
| if slide_color == 1 or slide_color == 6 or slide_color not in [1, 2, 3, 4, 5, 6, 7, 8]: | |
| return "recurve.png" # Logo branca | |
| else: | |
| return "recurveblack.png" # Logo preta | |
| def create_slide_canvas(image_url: str, slide: int, text: Optional[str] = None, color: int = 1, text_color: str = "white") -> BytesIO: | |
| """ | |
| Cria um canvas para slides com imagem cortada baseada no parâmetro slide. | |
| Args: | |
| image_url: URL da imagem de fundo | |
| slide: 1 para cortar início (880px), 2 para cortar final (400px) | |
| text: Texto opcional para exibir no slide | |
| color: Cor do slide (1-8) | |
| """ | |
| # Dimensões do poster | |
| canvas_width, canvas_height = 1080, 1350 | |
| # Obter cor do slide | |
| slide_bg_color = get_slide_color(color) | |
| # Criar canvas com cor do slide | |
| canvas = Image.new("RGBA", (canvas_width, canvas_height), color=slide_bg_color) | |
| # Baixar imagem original | |
| img = download_image_from_url(image_url) | |
| # PRIMEIRO: Usar resize_and_crop_to_fill para adaptar a imagem a 1280x780 (com zoom/crop centralizado) | |
| img_resized = resize_and_crop_to_fill(img, 1280, 780) | |
| # Configurações baseadas no slide | |
| if slide == 1: | |
| # Slide 1: Cortar os primeiros 880px (0 a 880) | |
| crop_left = 0 | |
| crop_right = 880 | |
| target_width = 880 | |
| target_height = 780 | |
| x_pos = 200 | |
| y_pos = 370 # Ajustado de 425 para 370 | |
| round_top_left = True | |
| round_bottom_left = True | |
| round_top_right = False | |
| round_bottom_right = False | |
| elif slide == 2: | |
| # Slide 2: Cortar os últimos 400px (880 a 1280) | |
| crop_left = 880 | |
| crop_right = 1280 | |
| target_width = 400 | |
| target_height = 780 | |
| x_pos = 0 | |
| y_pos = 370 # Ajustado de 425 para 370 | |
| round_top_left = False | |
| round_bottom_left = False | |
| round_top_right = True | |
| round_bottom_right = True | |
| else: | |
| raise HTTPException(status_code=400, detail="Parâmetro slide deve ser 1 ou 2") | |
| # Cortar a imagem (apenas crop, sem redimensionar) | |
| img_final = img_resized.crop((crop_left, 0, crop_right, 780)) | |
| # Criar máscara para bordas arredondadas | |
| mask = Image.new("L", (target_width, target_height), 0) | |
| draw_mask = ImageDraw.Draw(mask) | |
| # Desenhar retângulo base | |
| draw_mask.rectangle([(0, 0), (target_width, target_height)], fill=255) | |
| # Arredondar cantos específicos (25px de raio) | |
| radius = 25 | |
| if round_top_left: | |
| # Apagar o canto e redesenhar arredondado | |
| draw_mask.rectangle([(0, 0), (radius, radius)], fill=0) | |
| draw_mask.pieslice([(0, 0), (radius * 2, radius * 2)], 180, 270, fill=255) | |
| if round_bottom_left: | |
| draw_mask.rectangle([(0, target_height - radius), (radius, target_height)], fill=0) | |
| draw_mask.pieslice([(0, target_height - radius * 2), (radius * 2, target_height)], 90, 180, fill=255) | |
| if round_top_right: | |
| draw_mask.rectangle([(target_width - radius, 0), (target_width, radius)], fill=0) | |
| draw_mask.pieslice([(target_width - radius * 2, 0), (target_width, radius * 2)], 270, 360, fill=255) | |
| if round_bottom_right: | |
| draw_mask.rectangle([(target_width - radius, target_height - radius), (target_width, target_height)], fill=0) | |
| draw_mask.pieslice([(target_width - radius * 2, target_height - radius * 2), (target_width, target_height)], 0, 90, fill=255) | |
| # Aplicar máscara à imagem | |
| img_with_mask = Image.new("RGBA", (target_width, target_height)) | |
| img_with_mask.paste(img_final, (0, 0)) | |
| img_with_mask.putalpha(mask) | |
| # Colar imagem no canvas | |
| canvas.paste(img_with_mask, (x_pos, y_pos), img_with_mask) | |
| # Obter cor do texto baseada no parâmetro text_color | |
| if text_color.lower() == "black": | |
| text_color_tuple = (0, 0, 0, 255) # #000000 | |
| else: # white (padrão) | |
| text_color_tuple = (255, 255, 255, 255) # branco | |
| # Adicionar texto se slide for 1 e text for fornecido | |
| if slide == 1 and text and text.strip(): | |
| draw = ImageDraw.Draw(canvas) | |
| # Renderizar texto com as especificações: X: 200, Y: 155, L: 815, MAX 5 linhas | |
| render_slide_text(draw, text, x=200, y=155, max_width=815, max_lines=5, text_color=text_color_tuple) | |
| # Adicionar texto se slide for 2 e text for fornecido | |
| if slide == 2 and text and text.strip(): | |
| draw = ImageDraw.Draw(canvas) | |
| # Renderizar texto com as especificações: X: 445, Y: 370, L: 585, MAX 19 linhas | |
| # A imagem do slide 2 agora vai de Y: 370 a Y: 1150 (altura 780px) | |
| # Para centralizar o texto na área da imagem, o centro é Y: 370 + (780/2) = 760 | |
| render_slide_text(draw, text, x=445, y=760, max_width=585, max_lines=19, center_vertical=True, text_color=text_color_tuple) | |
| # Adicionar logo no canto inferior direito | |
| try: | |
| logo_path = get_logo_path(color) | |
| logo_width, logo_height = 121, 23 | |
| logo = Image.open(logo_path).convert("RGBA") | |
| logo_resized = logo.resize((logo_width, logo_height)) | |
| # Aplicar opacidade de 42% | |
| logo_with_opacity = Image.new("RGBA", logo_resized.size) | |
| for x in range(logo_resized.width): | |
| for y in range(logo_resized.height): | |
| r, g, b, a = logo_resized.getpixel((x, y)) | |
| new_alpha = int(a * 0.42) # 42% de opacidade | |
| logo_with_opacity.putpixel((x, y), (r, g, b, new_alpha)) | |
| # Posição: X: 880, Y: 1260 (canto inferior direito) | |
| canvas.paste(logo_with_opacity, (880, 1260), logo_with_opacity) | |
| except Exception as e: | |
| # Se não conseguir carregar a logo, continuar sem ela | |
| pass | |
| buffer = BytesIO() | |
| canvas.convert("RGB").save(buffer, format="PNG") | |
| buffer.seek(0) | |
| return buffer | |
| def create_quote_canvas(quote_text: str, role: Optional[str] = None, name: Optional[str] = None, color: int = 1, quote_background: bool = False, image_url: Optional[str] = None, text_color: str = "white") -> BytesIO: | |
| """ | |
| Cria um canvas para quotes com fundo colorido, imagem quote.png e texto centralizado. | |
| Args: | |
| quote_text: Texto da quote a ser exibido | |
| role: Cargo da pessoa que falou a quote | |
| name: Nome da pessoa que falou a quote | |
| color: Cor do fundo (1-8) | |
| quote_background: Se True, centraliza texto, role/name e símbolo da quote | |
| image_url: URL da imagem de fundo (usado quando quote_background=True) | |
| """ | |
| # Dimensões do poster | |
| canvas_width, canvas_height = 1080, 1350 | |
| # Criar canvas baseado no quote_background | |
| if quote_background and image_url: | |
| # Quando quote_background=True, usar image_url como fundo | |
| try: | |
| response = requests.get(image_url, timeout=10) | |
| response.raise_for_status() | |
| background_img = Image.open(BytesIO(response.content)).convert("RGBA") | |
| # Calcular proporções para fazer zoom centralizado | |
| img_width, img_height = background_img.size | |
| canvas_ratio = canvas_width / canvas_height | |
| img_ratio = img_width / img_height | |
| if img_ratio > canvas_ratio: | |
| # Imagem é mais larga - ajustar pela altura | |
| new_height = canvas_height | |
| new_width = int(canvas_height * img_ratio) | |
| else: | |
| # Imagem é mais alta - ajustar pela largura | |
| new_width = canvas_width | |
| new_height = int(canvas_width / img_ratio) | |
| # Redimensionar imagem mantendo proporção | |
| background_img = background_img.resize((new_width, new_height), Image.Resampling.LANCZOS) | |
| # Criar canvas e colar imagem centralizada | |
| canvas = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0)) | |
| x_offset = (canvas_width - new_width) // 2 | |
| y_offset = (canvas_height - new_height) // 2 | |
| canvas.paste(background_img, (x_offset, y_offset)) | |
| # Adicionar gradiente preto sobre a imagem | |
| gradient = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0)) | |
| gradient_draw = ImageDraw.Draw(gradient) | |
| # Criar gradiente linear de baixo para cima | |
| # 0% (bottom): #000000 100% opacidade | |
| # 25%: #000000 95% opacidade | |
| # 100% (top): #000000 0% opacidade | |
| for y in range(canvas_height): | |
| # Calcular posição relativa (0 = top, 1 = bottom) | |
| relative_y = (canvas_height - y) / canvas_height | |
| if relative_y <= 0.25: # De 0% a 25% (bottom para cima) | |
| # Transição de 100% a 95% opacidade | |
| alpha = int(255 * (1.0 - 0.05 * (relative_y / 0.25))) | |
| else: # De 25% a 100% (25% para topo) | |
| # Transição de 95% a 0% opacidade | |
| progress = (relative_y - 0.25) / 0.75 | |
| alpha = int(255 * 0.95 * (1 - progress)) | |
| # Desenhar linha horizontal com opacidade calculada | |
| gradient_draw.line([(0, y), (canvas_width, y)], fill=(0, 0, 0, alpha)) | |
| # Aplicar gradiente sobre a imagem | |
| canvas = Image.alpha_composite(canvas, gradient) | |
| except Exception as e: | |
| # Se falhar, usar cor padrão | |
| bg_color = get_slide_color(color) | |
| canvas = Image.new("RGBA", (canvas_width, canvas_height), color=bg_color) | |
| else: | |
| # Quando quote_background=False, usar cor de fundo baseada no parâmetro color | |
| bg_color = get_slide_color(color) | |
| canvas = Image.new("RGBA", (canvas_width, canvas_height), color=bg_color) | |
| draw = ImageDraw.Draw(canvas) | |
| # Configurações do texto | |
| if quote_background: | |
| # Largura maior quando quote_background=True | |
| text_width = 915 | |
| # Centralizar texto quando quote_background=True | |
| text_x = (canvas_width - text_width) // 2 # Centralizar horizontalmente | |
| # Posicionar embaixo quando quote_background=True | |
| text_y = 1160 # Base em Y: 1160 (60px mais baixo) | |
| else: | |
| # Largura padrão quando quote_background=False | |
| text_width = 720 | |
| # Alinhar à esquerda quando quote_background=False | |
| text_x = 180 | |
| text_y = canvas_height // 2 # Centralizar verticalmente | |
| # Obter cor do texto baseada no parâmetro text_color | |
| if text_color.lower() == "black": | |
| text_color_tuple = (0, 0, 0, 255) # #000000 | |
| else: # white (padrão) | |
| text_color_tuple = (255, 255, 255, 255) # branco | |
| # Adicionar imagem quote.png ou quoteblank.png acima do texto | |
| try: | |
| # Escolher imagem baseada no quote_background e text_color | |
| if quote_background: | |
| # Quando quote_background=True, sempre usar quotered.png | |
| quote_image_name = "quotered.png" | |
| else: | |
| # Quando quote_background=False, escolher baseado no text_color | |
| if text_color.lower() == "black": | |
| # Se text_color=black, usar quote.png (preta) | |
| quote_image_name = "quote.png" | |
| else: | |
| # Se text_color=white, usar quotered.png (vermelha) | |
| quote_image_name = "quotered.png" | |
| quote_img = Image.open(quote_image_name).convert("RGBA") | |
| quote_width, quote_height = 87, 67.46 | |
| # Redimensionar imagem para as dimensões especificadas | |
| quote_resized = quote_img.resize((int(quote_width), int(quote_height))) | |
| # Posição X baseada no parâmetro quote_background | |
| if quote_background: | |
| # Centralizar horizontalmente quando quote_background=True | |
| quote_x = (canvas_width - quote_width) // 2 | |
| else: | |
| # Alinhar à esquerda quando quote_background=False | |
| quote_x = 180 | |
| # Calcular altura do texto usando o tamanho de fonte real que será usado | |
| # Usar a mesma lógica do render_quote_text para garantir consistência | |
| try: | |
| if quote_background: | |
| regular_font_path = "fonts/Montserrat-SemiBold.ttf" | |
| else: | |
| regular_font_path = "fonts/Montserrat-Regular.ttf" | |
| except (OSError, IOError): | |
| regular_font_path = None | |
| def get_max_lines(font_size): | |
| if quote_background: | |
| # Quando quote_background=True, máximo de 3 linhas independente do tamanho | |
| return 3 | |
| else: | |
| # Comportamento original quando quote_background=False | |
| if font_size >= 65: | |
| return 8 | |
| elif font_size >= 55: | |
| return 9 | |
| elif font_size >= 45: | |
| return 10 | |
| elif font_size >= 35: | |
| return 11 | |
| elif font_size >= 30: | |
| return 12 | |
| elif font_size >= 25: | |
| return 13 | |
| elif font_size >= 20: | |
| return 14 | |
| elif font_size >= 16: | |
| return 14 | |
| else: | |
| return 14 | |
| def wrap_text(text, max_width, font_size): | |
| lines = [] | |
| current_line = [] | |
| try: | |
| font = ImageFont.truetype(regular_font_path, font_size) if regular_font_path else ImageFont.load_default() | |
| except (OSError, IOError): | |
| font = ImageFont.load_default() | |
| words = text.split() | |
| for word in words: | |
| test_line = " ".join(current_line + [word]) | |
| bbox = font.getbbox(test_line) | |
| line_width = bbox[2] - bbox[0] | |
| if line_width <= max_width: | |
| current_line.append(word) | |
| else: | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| current_line = [word] | |
| else: | |
| lines.append(word) | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| return lines, font | |
| # Encontrar o tamanho de fonte ideal (mesma lógica do render_quote_text) | |
| if quote_background: | |
| font_size = 42 # Tamanho inicial quando quote_background=True | |
| else: | |
| font_size = 65 # Tamanho inicial quando quote_background=False | |
| min_font_size = 12 | |
| while font_size >= min_font_size: | |
| max_lines = get_max_lines(font_size) | |
| lines, font = wrap_text(quote_text, text_width, font_size) | |
| if len(lines) <= max_lines: | |
| break | |
| font_size -= 2 | |
| font_size = max(font_size, min_font_size) | |
| # Calcular altura total do texto com o tamanho de fonte real | |
| lines, font = wrap_text(quote_text, text_width, font_size) | |
| total_text_height = 0 | |
| for line in lines: | |
| bbox = font.getbbox(line) | |
| line_height = bbox[3] - bbox[1] | |
| total_text_height += int(line_height * 1.20) | |
| # Posicionar imagem baseado no quote_background | |
| if quote_background: | |
| # Quando quote_background=True, posicionar acima do texto (que começa em Y: 1160) | |
| quote_y = text_y - total_text_height - 80 - quote_height | |
| else: | |
| # Espaçamento normal quando quote_background=False | |
| quote_y = text_y - (total_text_height // 2) - 60 - (quote_height // 2) | |
| # Colar imagem no canvas | |
| canvas.paste(quote_resized, (int(quote_x), int(quote_y)), quote_resized) | |
| except Exception as e: | |
| # Se não conseguir carregar a imagem, continuar sem ela | |
| pass | |
| # Adicionar símbolo " no final do texto automaticamente | |
| quote_text_with_symbol = quote_text + '”' | |
| # Renderizar texto com as especificações solicitadas | |
| render_quote_text(draw, quote_text_with_symbol, text_x, text_y, text_width, text_color_tuple, quote_background) | |
| # Renderizar cargo e nome se fornecidos | |
| if role or name: | |
| render_author_info(draw, canvas, role, name, text_x, text_y, text_width, quote_text_with_symbol, text_color_tuple, quote_background) | |
| buffer = BytesIO() | |
| canvas.convert("RGB").save(buffer, format="PNG") | |
| buffer.seek(0) | |
| return buffer | |
| def render_quote_text(draw: ImageDraw.Draw, text: str, x: int, y: int, max_width: int, text_color: tuple = (0, 0, 0, 255), quote_background: bool = False) -> None: | |
| """ | |
| Renderiza texto para quotes com fonte Montserrat Regular, tamanho inicial 65px. | |
| Sistema dinâmico de linhas: 6 linhas em 65px, aumentando até 14 linhas conforme fonte diminui. | |
| Args: | |
| draw: Objeto ImageDraw para desenhar | |
| text: Texto a ser renderizado | |
| x: Posição X (esquerda) | |
| y: Posição Y (centro vertical do texto) | |
| max_width: Largura máxima do texto (720px) | |
| text_color: Cor do texto como tuple (R, G, B, A) | |
| quote_background: Se True, centraliza o texto | |
| """ | |
| if not text.strip(): | |
| return | |
| # Carregar fonte baseada no parâmetro quote_background | |
| try: | |
| if quote_background: | |
| regular_font_path = "fonts/Montserrat-SemiBold.ttf" | |
| else: | |
| regular_font_path = "fonts/Montserrat-Regular.ttf" | |
| except (OSError, IOError): | |
| regular_font_path = None | |
| # Função para calcular máximo de linhas baseado no tamanho da fonte | |
| def get_max_lines(font_size): | |
| """Calcula o máximo de linhas baseado no tamanho da fonte""" | |
| if quote_background: | |
| # Quando quote_background=True, máximo de 3 linhas independente do tamanho | |
| return 3 | |
| else: | |
| # Comportamento original quando quote_background=False | |
| if font_size >= 65: | |
| return 8 | |
| elif font_size >= 55: | |
| return 9 | |
| elif font_size >= 45: | |
| return 10 | |
| elif font_size >= 35: | |
| return 11 | |
| elif font_size >= 30: | |
| return 12 | |
| elif font_size >= 25: | |
| return 13 | |
| elif font_size >= 20: | |
| return 14 | |
| elif font_size >= 16: | |
| return 14 | |
| else: | |
| return 14 | |
| # Função para quebrar texto em linhas | |
| def wrap_text(text, max_width, font_size): | |
| lines = [] | |
| current_line = [] | |
| # Carregar fonte com tamanho específico | |
| try: | |
| font = ImageFont.truetype(regular_font_path, font_size) if regular_font_path else ImageFont.load_default() | |
| except (OSError, IOError): | |
| font = ImageFont.load_default() | |
| words = text.split() | |
| for word in words: | |
| test_line = " ".join(current_line + [word]) | |
| bbox = font.getbbox(test_line) | |
| line_width = bbox[2] - bbox[0] | |
| if line_width <= max_width: | |
| current_line.append(word) | |
| else: | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| current_line = [word] | |
| else: | |
| lines.append(word) | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| return lines, font | |
| # Encontrar o tamanho de fonte ideal | |
| if quote_background: | |
| font_size = 42 # Tamanho inicial quando quote_background=True | |
| else: | |
| font_size = 65 # Tamanho inicial quando quote_background=False | |
| min_font_size = 12 # Tamanho mínimo | |
| # Tentar diferentes tamanhos de fonte até encontrar um que caiba no máximo de linhas | |
| while font_size >= min_font_size: | |
| max_lines = get_max_lines(font_size) | |
| lines, font = wrap_text(text, max_width, font_size) | |
| # Se o texto cabe em max_lines ou menos, usar este tamanho | |
| if len(lines) <= max_lines: | |
| break | |
| # Reduzir o tamanho da fonte | |
| font_size -= 2 | |
| # Garantir que não seja menor que o tamanho mínimo | |
| font_size = max(font_size, min_font_size) | |
| # Carregar fonte final | |
| try: | |
| final_font = ImageFont.truetype(regular_font_path, font_size) if regular_font_path else ImageFont.load_default() | |
| except (OSError, IOError): | |
| final_font = ImageFont.load_default() | |
| # Quebrar texto final | |
| lines, _ = wrap_text(text, max_width, font_size) | |
| # Se ainda não couber no máximo de linhas, forçar quebra | |
| max_lines = get_max_lines(font_size) | |
| if len(lines) > max_lines: | |
| # Combinar as linhas restantes na última linha permitida | |
| combined_text = " ".join(lines[:max_lines-1]) + " " + " ".join(lines[max_lines-1:]) | |
| lines, _ = wrap_text(combined_text, max_width, font_size) | |
| lines = lines[:max_lines] # Garantir que não exceda max_lines | |
| # Calcular altura total do texto para centralização vertical | |
| total_text_height = 0 | |
| line_heights = [] | |
| for line in lines: | |
| bbox = final_font.getbbox(line) | |
| line_height = bbox[3] - bbox[1] | |
| line_heights.append(line_height) | |
| total_text_height += int(line_height * 1.20) # Aplicar line height de 120% | |
| # Ajustar posição Y baseado no quote_background | |
| if quote_background: | |
| # Alinhar pela base quando quote_background=True | |
| start_y = y - total_text_height | |
| else: | |
| # Centralizar verticalmente quando quote_background=False | |
| start_y = y - (total_text_height // 2) | |
| # Desenhar texto de cima para baixo | |
| current_y = start_y | |
| for line_idx, line in enumerate(lines): | |
| line_height = line_heights[line_idx] | |
| # Calcular posição X da linha | |
| if quote_background: | |
| # Centralizar linha quando quote_background=True | |
| line_bbox = final_font.getbbox(line) | |
| line_width = line_bbox[2] - line_bbox[0] | |
| line_x = x + (max_width - line_width) // 2 | |
| else: | |
| # Alinhar à esquerda quando quote_background=False | |
| line_x = x | |
| # Desenhar linha | |
| draw.text((line_x, current_y), line, fill=text_color, font=final_font) | |
| # Aplicar line height de 120% (adicionar 20% de espaçamento extra) | |
| current_y += int(line_height * 1.20) | |
| def wrap_text_simple(text: str, font, max_width: int) -> List[str]: | |
| """ | |
| Quebra texto em linhas respeitando a largura máxima. | |
| Args: | |
| text: Texto a ser quebrado | |
| font: Fonte para calcular largura | |
| max_width: Largura máxima por linha | |
| Returns: | |
| Lista de linhas | |
| """ | |
| if not text.strip(): | |
| return [] | |
| words = text.split() | |
| if not words: | |
| return [] | |
| lines = [] | |
| current_line = [] | |
| for word in words: | |
| test_line = " ".join(current_line + [word]) | |
| bbox = font.getbbox(test_line) | |
| line_width = bbox[2] - bbox[0] | |
| if line_width <= max_width: | |
| current_line.append(word) | |
| else: | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| current_line = [word] | |
| else: | |
| lines.append(word) | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| return lines | |
| def render_author_info(draw: ImageDraw.Draw, canvas: Image.Image, role: Optional[str], name: Optional[str], text_x: int, text_y: int, text_width: int, quote_text: str, text_color: tuple, quote_background: bool) -> None: | |
| """ | |
| Renderiza informações do autor (cargo e nome) abaixo do texto principal. | |
| Args: | |
| draw: Objeto ImageDraw para desenhar | |
| canvas: Canvas da imagem | |
| role: Cargo da pessoa | |
| name: Nome da pessoa | |
| text_x: Posição X do texto principal | |
| text_y: Posição Y do texto principal | |
| text_width: Largura do texto principal | |
| quote_text: Texto da quote | |
| text_color: Cor do texto principal (para determinar cor do role/name) | |
| quote_background: Se True, centraliza role/name | |
| """ | |
| if not role and not name: | |
| return | |
| # Carregar fonte Montserrat Bold | |
| try: | |
| bold_font_path = "fonts/Montserrat-Bold.ttf" | |
| font = ImageFont.truetype(bold_font_path, 32) | |
| except (OSError, IOError): | |
| font = ImageFont.load_default() | |
| # Determinar cores baseadas no text_color | |
| if text_color == (255, 255, 255, 255): # Se texto principal for branco | |
| # Cor do cargo: branco | |
| cargo_color = (255, 255, 255, 255) | |
| # Cor do nome: branco com 65% de opacidade | |
| nome_color = (255, 255, 255, int(255 * 0.65)) | |
| else: # Se texto principal for preto | |
| # Cor do cargo: 140F09 (RGB: 20, 15, 9) | |
| cargo_color = (20, 15, 9, 255) | |
| # Cor do nome: 140F09 com 65% de opacidade | |
| nome_color = (20, 15, 9, int(255 * 0.65)) | |
| # Calcular altura do texto principal para posicionar o autor | |
| # Usar a mesma lógica do render_quote_text para calcular a altura | |
| try: | |
| if quote_background: | |
| regular_font_path = "fonts/Montserrat-SemiBold.ttf" | |
| else: | |
| regular_font_path = "fonts/Montserrat-Regular.ttf" | |
| except (OSError, IOError): | |
| regular_font_path = None | |
| def get_max_lines(font_size): | |
| if quote_background: | |
| # Quando quote_background=True, máximo de 3 linhas independente do tamanho | |
| return 3 | |
| else: | |
| # Comportamento original quando quote_background=False | |
| if font_size >= 65: | |
| return 8 | |
| elif font_size >= 55: | |
| return 9 | |
| elif font_size >= 45: | |
| return 10 | |
| elif font_size >= 35: | |
| return 11 | |
| elif font_size >= 30: | |
| return 12 | |
| elif font_size >= 25: | |
| return 13 | |
| elif font_size >= 20: | |
| return 14 | |
| elif font_size >= 16: | |
| return 14 | |
| else: | |
| return 14 | |
| def wrap_text(text, max_width, font_size): | |
| lines = [] | |
| current_line = [] | |
| try: | |
| text_font = ImageFont.truetype(regular_font_path, font_size) if regular_font_path else ImageFont.load_default() | |
| except (OSError, IOError): | |
| text_font = ImageFont.load_default() | |
| words = text.split() | |
| for word in words: | |
| test_line = " ".join(current_line + [word]) | |
| bbox = text_font.getbbox(test_line) | |
| line_width = bbox[2] - bbox[0] | |
| if line_width <= max_width: | |
| current_line.append(word) | |
| else: | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| current_line = [word] | |
| else: | |
| lines.append(word) | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| return lines, text_font | |
| # Encontrar o tamanho de fonte ideal do texto principal | |
| if quote_background: | |
| font_size = 42 # Tamanho inicial quando quote_background=True | |
| else: | |
| font_size = 65 # Tamanho inicial quando quote_background=False | |
| min_font_size = 12 | |
| while font_size >= min_font_size: | |
| max_lines = get_max_lines(font_size) | |
| lines, text_font = wrap_text(quote_text, text_width, font_size) | |
| if len(lines) <= max_lines: | |
| break | |
| font_size -= 2 | |
| font_size = max(font_size, min_font_size) | |
| # Calcular altura total do texto principal | |
| lines, text_font = wrap_text(quote_text, text_width, font_size) | |
| total_text_height = 0 | |
| for line in lines: | |
| bbox = text_font.getbbox(line) | |
| line_height = bbox[3] - bbox[1] | |
| total_text_height += int(line_height * 1.20) | |
| # Posicionar informações do autor baseado no quote_background | |
| if quote_background: | |
| # Quando quote_background=True, posicionar abaixo do texto (que termina em Y: 1160) | |
| author_y = text_y + 28 | |
| else: | |
| # Espaçamento normal quando quote_background=False | |
| author_y = text_y + (total_text_height // 2) + 28 | |
| # Renderizar cargo e nome com vírgula | |
| if role and role.strip() and name and name.strip(): | |
| # Renderizar cargo + vírgula com quebra de linha | |
| role_text = f"{role}," | |
| role_lines = wrap_text_simple(role_text, font, text_width) | |
| current_y = author_y | |
| for line in role_lines: | |
| if quote_background: | |
| # Centralizar cargo quando quote_background=True | |
| role_bbox = font.getbbox(line) | |
| role_width = role_bbox[2] - role_bbox[0] | |
| role_x = text_x + (text_width - role_width) // 2 | |
| else: | |
| # Alinhar à esquerda quando quote_background=False | |
| role_x = text_x | |
| draw.text((role_x, current_y), line, fill=cargo_color, font=font) | |
| bbox = font.getbbox(line) | |
| current_y += bbox[3] - bbox[1] + 5 # Adicionar pequeno espaçamento entre linhas | |
| author_y = current_y + 5 # Adicionar pequeno espaçamento entre cargo e nome | |
| # Renderizar nome com opacidade e quebra de linha | |
| name_lines = wrap_text_simple(name, font, text_width) | |
| for line in name_lines: | |
| # Criar uma imagem temporária para cada linha do nome com opacidade | |
| name_bbox = font.getbbox(line) | |
| name_width = name_bbox[2] - name_bbox[0] | |
| name_height = name_bbox[3] - name_bbox[1] | |
| # Adicionar padding para evitar corte | |
| padding = 10 | |
| name_img_width = name_width + (padding * 2) | |
| name_img_height = name_height + (padding * 2) | |
| # Criar imagem temporária para a linha do nome com padding | |
| name_img = Image.new("RGBA", (name_img_width, name_img_height), (0, 0, 0, 0)) | |
| name_draw = ImageDraw.Draw(name_img) | |
| name_draw.text((padding, padding), line, fill=nome_color, font=font) | |
| # Calcular posição X da linha do nome | |
| if quote_background: | |
| # Centralizar nome quando quote_background=True | |
| name_x = text_x + (text_width - name_width) // 2 - padding | |
| else: | |
| # Alinhar à esquerda quando quote_background=False | |
| name_x = text_x - padding | |
| # Colar a imagem da linha do nome no canvas principal (ajustar posição considerando o padding) | |
| canvas.paste(name_img, (name_x, author_y - padding), name_img) | |
| # Avançar para próxima linha | |
| author_y += name_height + 5 | |
| elif role and role.strip(): | |
| # Apenas cargo com quebra de linha | |
| role_lines = wrap_text_simple(role, font, text_width) | |
| current_y = author_y | |
| for line in role_lines: | |
| if quote_background: | |
| # Centralizar cargo quando quote_background=True | |
| role_bbox = font.getbbox(line) | |
| role_width = role_bbox[2] - role_bbox[0] | |
| role_x = text_x + (text_width - role_width) // 2 | |
| else: | |
| # Alinhar à esquerda quando quote_background=False | |
| role_x = text_x | |
| draw.text((role_x, current_y), line, fill=cargo_color, font=font) | |
| bbox = font.getbbox(line) | |
| current_y += bbox[3] - bbox[1] + 5 # Adicionar pequeno espaçamento entre linhas | |
| elif name and name.strip(): | |
| # Apenas nome com opacidade e quebra de linha | |
| name_lines = wrap_text_simple(name, font, text_width) | |
| current_y = author_y | |
| for line in name_lines: | |
| # Criar uma imagem temporária para cada linha do nome com opacidade | |
| name_bbox = font.getbbox(line) | |
| name_width = name_bbox[2] - name_bbox[0] | |
| name_height = name_bbox[3] - name_bbox[1] | |
| # Adicionar padding para evitar corte | |
| padding = 10 | |
| name_img_width = name_width + (padding * 2) | |
| name_img_height = name_height + (padding * 2) | |
| # Criar imagem temporária para a linha do nome com padding | |
| name_img = Image.new("RGBA", (name_img_width, name_img_height), (0, 0, 0, 0)) | |
| name_draw = ImageDraw.Draw(name_img) | |
| name_draw.text((padding, padding), line, fill=nome_color, font=font) | |
| # Calcular posição X da linha do nome | |
| if quote_background: | |
| # Centralizar nome quando quote_background=True | |
| name_x = text_x + (text_width - name_width) // 2 - padding | |
| else: | |
| # Alinhar à esquerda quando quote_background=False | |
| name_x = text_x - padding | |
| # Colar a imagem da linha do nome no canvas principal (ajustar posição considerando o padding) | |
| canvas.paste(name_img, (name_x, current_y - padding), name_img) | |
| # Avançar para próxima linha | |
| current_y += name_height + 5 | |
| def create_canvas(image_url: str, text: Optional[str] = None, | |
| text_position: str = "bottom", background: bool = False, color: int = 1, background_position: str = "bottom", text_color: str = "white") -> BytesIO: | |
| # Dimensões fixas do Instagram | |
| width, height = 1080, 1350 | |
| # Configurações da logo | |
| logo_width, logo_height = 121, 23 | |
| # Se background=True, forçar posições baseadas em background_position | |
| if background: | |
| if background_position == "top": | |
| text_position = "top" | |
| else: # bottom | |
| text_position = "bottom" | |
| # Obter cor do fundo | |
| bg_color = get_slide_color(color) | |
| # Criar canvas | |
| canvas = Image.new("RGBA", (width, height), color=bg_color) | |
| # Calcular altura do background se necessário | |
| background_height = 0 | |
| background_start_y = 0 | |
| if background: | |
| # Calcular altura necessária para o texto | |
| padding_bottom = height - 1260 # Espaço até a logo (90px) | |
| # Calcular altura do texto | |
| text_height = 0 | |
| if text and text.strip(): | |
| try: | |
| text_font_path = "fonts/Montserrat-Regular.ttf" | |
| text_font = ImageFont.truetype(text_font_path, 32) | |
| except (OSError, IOError): | |
| text_font = ImageFont.load_default() | |
| text_height = calculate_text_height(text, text_font, 924) | |
| # Calcular altura do conteúdo | |
| content_height = text_height | |
| if background_position == "top": | |
| # Background no topo: começar do topo e descer com base no conteúdo | |
| background_start_y = 0 | |
| background_height = content_height + 60 + 60 # 60px de padding superior + conteúdo + 60px de padding inferior | |
| # Garantir altura mínima | |
| if background_height < 200: | |
| background_height = 200 | |
| else: # bottom | |
| # Background embaixo: usar a mesma posição de referência do modo normal | |
| bottom_y = 1180 | |
| # Calcular de onde o background deve começar | |
| # Partir do bottom_y e subir com base no conteúdo | |
| background_start_y = bottom_y - content_height - 60 # 60px de padding superior | |
| # Altura total do background (do início até o fim da página) | |
| background_height = height - background_start_y | |
| # Garantir altura mínima | |
| if background_height < 200: | |
| background_start_y = height - 200 | |
| background_height = 200 | |
| # Calcular altura da imagem | |
| image_height = height - background_height if background else height | |
| # Adicionar imagem de fundo | |
| img = download_image_from_url(image_url) | |
| filled_img = resize_and_crop_to_fill(img, width, image_height) | |
| if background and background_position == "top": | |
| # Background no topo: colar imagem abaixo do background | |
| canvas.paste(filled_img, (0, background_height)) | |
| else: | |
| # Background embaixo ou sem background: colar imagem no topo | |
| canvas.paste(filled_img, (0, 0)) | |
| # Adicionar background colorido se necessário | |
| if background: | |
| # Criar retângulo com a cor selecionada | |
| background_rect = Image.new("RGBA", (width, background_height), color=bg_color) | |
| canvas.paste(background_rect, (0, background_start_y)) | |
| else: | |
| # Adicionar gradiente apenas se background=False e text_color não for black | |
| # Lógica inteligente para posicionamento do gradiente | |
| # Se text_color=black, não adicionar gradientes pretos | |
| if text_color.lower() != "black": | |
| # Se text_position=bottom, sempre gradiente embaixo (obrigatório) | |
| if text_position == "bottom": | |
| gradient_overlay = create_gradient_overlay(width, height, "bottom") | |
| canvas = Image.alpha_composite(canvas, gradient_overlay) | |
| # Se text_position=top | |
| elif text_position == "top": | |
| # Sempre gradiente em cima quando texto está no topo | |
| gradient_overlay = create_gradient_overlay(width, height, "top") | |
| canvas = Image.alpha_composite(canvas, gradient_overlay) | |
| # Adicionar logo na posição (X: 880, Y: 1260) | |
| try: | |
| # Se background_position for top, sempre usar logo branca | |
| if background and background_position == "top": | |
| logo_path = "recurve.png" # Logo branca sempre | |
| else: | |
| logo_path = get_logo_path(color) | |
| logo = Image.open(logo_path).convert("RGBA") | |
| logo_resized = logo.resize((logo_width, logo_height)) | |
| # Aplicar opacidade de 42% | |
| logo_with_opacity = Image.new("RGBA", logo_resized.size) | |
| for x in range(logo_resized.width): | |
| for y in range(logo_resized.height): | |
| r, g, b, a = logo_resized.getpixel((x, y)) | |
| new_alpha = int(a * 0.42) # 42% de opacidade | |
| logo_with_opacity.putpixel((x, y), (r, g, b, new_alpha)) | |
| # Posição: X:880, Y:1260 | |
| canvas.paste(logo_with_opacity, (880, 1260), logo_with_opacity) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Erro ao carregar a logo: {e}") | |
| draw = ImageDraw.Draw(canvas) | |
| # Definir posições baseadas nos parâmetros | |
| if background: | |
| if background_position == "top": | |
| # Background no topo: posicionar texto no topo | |
| top_y = 60 # Padding do topo | |
| text_y = top_y if text and text.strip() else None | |
| else: # bottom | |
| # Com background embaixo: usar mesma posição de referência do modo normal | |
| bottom_y = 1180 | |
| text_y = bottom_y if text and text.strip() else None | |
| else: | |
| # Posições de referência (comportamento original) | |
| top_y = 60 | |
| bottom_y = 1180 | |
| # Determinar posição do texto | |
| if text and text.strip(): | |
| if text_position == "top": | |
| text_y = top_y | |
| else: # bottom | |
| text_y = bottom_y | |
| else: | |
| text_y = None | |
| # Obter cor do texto baseada no parâmetro text_color | |
| if text_color.lower() == "black": | |
| text_color_tuple = (0, 0, 0, 255) # #000000 | |
| else: # white (padrão) | |
| text_color_tuple = (255, 255, 255, 255) # branco | |
| # Renderizar texto se fornecido | |
| if text and text.strip() and text_y is not None: | |
| # Alinhar pela base quando no bottom; pelo topo quando no top | |
| align_text_top = (text_position == "top") | |
| render_text(draw, text, x=78, y=text_y, max_width=924, max_lines=5, align_top=align_text_top, text_color=text_color_tuple) | |
| buffer = BytesIO() | |
| canvas.convert("RGB").save(buffer, format="PNG") | |
| buffer.seek(0) | |
| return buffer | |
| def get_image( | |
| image_url: str = Query(..., description="URL da imagem de fundo"), | |
| text: Optional[str] = Query(None, description="Texto a ser exibido na imagem"), | |
| text_position: str = Query("bottom", description="Posição do texto: 'top' ou 'bottom'"), | |
| background: bool = Query(False, description="Se True, adiciona background colorido e força posições baseadas em background_position"), | |
| background_position: str = Query("bottom", description="Posição do background: 'top' ou 'bottom'"), | |
| slide: Optional[int] = Query(None, description="Número do slide: 1 para início (880px), 2 para final (400px)"), | |
| color: Optional[int] = Query(1, description="Cor do fundo: 1=#000000, 2=#BFC693, 3=#f6a41d, 4=#f5f4ef, 5=#6fb4a5, 6=#1953d0, 7=#ff5011, 8=#ffd9c2"), | |
| quote: Optional[str] = Query(None, description="Texto da quote para gerar poster com fundo colorido"), | |
| role: Optional[str] = Query(None, description="Position/role of the person who said the quote"), | |
| name: Optional[str] = Query(None, description="Name of the person who said the quote"), | |
| quote_background: bool = Query(False, description="Se True, centraliza texto, role/name e símbolo da quote"), | |
| text_color: str = Query("white", description="Cor do texto: 'white' ou 'black'") | |
| ): | |
| try: | |
| # Se o parâmetro quote for fornecido, usar a funcionalidade de quote | |
| if quote is not None and quote.strip(): | |
| buffer = create_quote_canvas(quote, role, name, color, quote_background, image_url, text_color) | |
| # Se o parâmetro slide for fornecido, usar a funcionalidade de slides | |
| elif slide is not None: | |
| buffer = create_slide_canvas(image_url, slide, text, color, text_color) | |
| else: | |
| # Usar funcionalidade normal de criação de imagem | |
| buffer = create_canvas(image_url, text, text_position, background, color, background_position, text_color) | |
| return StreamingResponse(buffer, media_type="image/png") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}") |