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 | |
| import textwrap | |
| 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: linear-gradient(360deg, 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: 0px, width: 1080px, height: 706px | |
| gradient_start = 0 | |
| gradient_height = 706 | |
| for y in range(gradient_height): | |
| if y < 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), (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_responsive_text(draw: ImageDraw.Draw, text: str, x: int, y: int, max_width: int, max_lines: int = 3, text_color: str = "white", text_position: str = "bottom") -> None: | |
| """ | |
| Renderiza texto responsivo que se ajusta automaticamente ao tamanho da fonte | |
| para caber em até max_lines linhas dentro da largura especificada. | |
| Args: | |
| draw: Objeto ImageDraw para desenhar | |
| text: Texto a ser renderizado | |
| x: Posição X (esquerda) | |
| y: Posição Y (base para bottom, topo para top) | |
| max_width: Largura máxima do texto | |
| max_lines: Número máximo de linhas (padrão: 3) | |
| text_color: Cor do texto ("white" ou "black") | |
| text_position: Posição do texto ("bottom" ou "top") | |
| """ | |
| if not text.strip(): | |
| return | |
| # Carregar fonte | |
| try: | |
| font_path = "fonts/cheltenham-italic-800.ttf" | |
| except (OSError, IOError): | |
| font_path = None | |
| # Dividir texto em palavras | |
| words = text.split() | |
| if not words: | |
| return | |
| # Função para quebrar texto em linhas | |
| def wrap_text(text, font_size, max_width): | |
| if font_path: | |
| try: | |
| test_font = ImageFont.truetype(font_path, font_size) | |
| except (OSError, IOError): | |
| test_font = ImageFont.load_default() | |
| else: | |
| test_font = ImageFont.load_default() | |
| lines = [] | |
| current_line = [] | |
| for word in words: | |
| test_line = " ".join(current_line + [word]) | |
| bbox = draw.textbbox((0, 0), test_line, font=test_font) | |
| 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: | |
| # Palavra muito longa, adiciona mesmo assim | |
| lines.append(word) | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| return lines | |
| # Encontrar o tamanho de fonte ideal | |
| font_size = 80 # Tamanho inicial | |
| min_font_size = 15 # Tamanho mínimo menor para mais flexibilidade | |
| # Tentar diferentes tamanhos de fonte até encontrar um que caiba em max_lines | |
| while font_size >= min_font_size: | |
| lines = wrap_text(text, font_size, max_width) | |
| # 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 # Redução menor para melhor precisão | |
| # Garantir que não seja menor que o tamanho mínimo | |
| font_size = max(font_size, min_font_size) | |
| # Carregar fonte final | |
| if font_path: | |
| try: | |
| final_font = ImageFont.truetype(font_path, font_size) | |
| except (OSError, IOError): | |
| final_font = ImageFont.load_default() | |
| else: | |
| final_font = ImageFont.load_default() | |
| # Quebrar texto final | |
| lines = wrap_text(text, font_size, max_width) | |
| # 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_text = " ".join(lines[:max_lines-1] + [" ".join(lines[max_lines-1:])]) | |
| lines = wrap_text(combined_text, font_size, max_width) | |
| lines = lines[:max_lines] # Garantir que não exceda max_lines | |
| # Calcular altura total do texto | |
| line_heights = [] | |
| for line in lines: | |
| bbox = draw.textbbox((0, 0), line, font=final_font) | |
| line_height = bbox[3] - bbox[1] | |
| line_heights.append(line_height) | |
| # Determinar cor do texto | |
| if text_color.lower() == "black": | |
| fill_color = (0, 0, 0, 255) # Preto | |
| else: | |
| fill_color = (255, 255, 255, 255) # Branco (padrão) | |
| # Desenhar texto baseado na posição | |
| if text_position.lower() == "top": | |
| # Desenhar texto de cima para baixo (alinhamento ao topo) | |
| current_y = y | |
| for i, line in enumerate(lines): | |
| bbox = draw.textbbox((0, 0), line, font=final_font) | |
| line_height = bbox[3] - bbox[1] | |
| draw.text((x, current_y), line, fill=fill_color, font=final_font) | |
| # Aplicar line height de 120% (adicionar 20% de espaçamento extra) | |
| current_y += int(line_height * 1.20) | |
| else: | |
| # Desenhar texto de baixo para cima (alinhamento à base) | |
| current_y = y | |
| for i, line in enumerate(reversed(lines)): | |
| line_height = line_heights[len(lines) - 1 - i] | |
| # Aplicar line height de 120% (adicionar 20% de espaçamento extra) | |
| current_y -= int(line_height * 1.20) | |
| draw.text((x, current_y), line, fill=fill_color, font=final_font) | |
| def create_canvas(image_url: Optional[str], text: Optional[str] = None, text_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 | |
| canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255)) | |
| # Adicionar imagem de fundo se fornecida | |
| if image_url: | |
| img = download_image_from_url(image_url) | |
| filled_img = resize_and_crop_to_fill(img, width, height) | |
| canvas.paste(filled_img, (0, 0)) | |
| # Adicionar gradiente (apenas se não for texto preto) | |
| if text_color.lower() != "black": | |
| gradient_overlay = create_gradient_overlay(width, height, text_position) | |
| canvas = Image.alpha_composite(canvas, gradient_overlay) | |
| # Adicionar logo na nova posição (X: 880, Y: 1260) | |
| try: | |
| logo_path = "recurve.png" | |
| 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)) | |
| # Nova 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}") | |
| # Adicionar texto se fornecido | |
| if text and text.strip(): | |
| draw = ImageDraw.Draw(canvas) | |
| # Determinar posição Y baseada no text_position | |
| if text_position.lower() == "top": | |
| text_y = 60 # Posição para texto no topo com espaçamento de 60px | |
| else: | |
| text_y = 1180 # Posição para texto embaixo (padrão) | |
| # Configurações do texto: X: 78, largura: 924px, alinhado à base e à esquerda | |
| render_responsive_text(draw, text, x=78, y=text_y, max_width=924, max_lines=3, text_color=text_color, text_position=text_position) | |
| buffer = BytesIO() | |
| canvas.convert("RGB").save(buffer, format="PNG") | |
| buffer.seek(0) | |
| return buffer | |
| def get_news_image( | |
| image_url: Optional[str] = Query(None, 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: 'bottom' ou 'top'"), | |
| text_color: str = Query("white", description="Cor do texto: 'white' ou 'black'") | |
| ): | |
| try: | |
| buffer = create_canvas(image_url, text, text_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)}") |