from fastapi import APIRouter, Query, HTTPException from fastapi.responses import StreamingResponse from PIL import Image, ImageDraw, ImageFont from io import BytesIO import requests import re from typing import Optional, List, Tuple 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: img_ratio = img.width / img.height target_ratio = target_width / target_height if img_ratio > target_ratio: scale_height = target_height scale_width = int(scale_height * img_ratio) else: scale_width = target_width scale_height = int(scale_width / img_ratio) img_resized = img.resize((scale_width, scale_height), Image.LANCZOS) left = (scale_width - target_width) // 2 top = (scale_height - target_height) // 2 return img_resized.crop((left, top, left + target_width, top + target_height)) def create_gradient_overlay(width: int, height: int, positions: List[str], expanded: bool = False) -> Image.Image: gradient = Image.new("RGBA", (width, height)) draw = ImageDraw.Draw(gradient) for position in positions: if position.lower() == "bottom": if expanded: # Gradiente expandido para quando há texto + citação gradient_start = 500 # Começar mais alto gradient_height = 850 # Mais altura para cobrir ambos else: # Gradiente normal gradient_start = 650 # Começar mais baixo para ser mais sutil gradient_height = 700 # Altura menor para gradiente mais localizado for y in range(gradient_height): if y + gradient_start < height: ratio = y / gradient_height # Gradiente muito mais suave com opacidades menores if ratio <= 0.3: opacity = int(255 * 0.15 * (ratio / 0.3)) # Começar muito sutil elif ratio <= 0.6: opacity = int(255 * (0.15 + 0.20 * ((ratio - 0.3) / 0.3))) # Crescimento gradual else: opacity = int(255 * (0.35 + 0.15 * ((ratio - 0.6) / 0.4))) # Max de 50% de opacidade current_pixel = gradient.getpixel((0, y + gradient_start)) combined_opacity = min(255, current_pixel[3] + opacity) draw.line([(0, y + gradient_start), (width, y + gradient_start)], fill=(0, 0, 0, combined_opacity)) elif position.lower() == "top": if expanded: # Gradiente expandido para quando há texto + citação gradient_height = 850 # Maior altura para cobrir ambos else: # Gradiente normal gradient_height = 650 # Menor altura para ser mais sutil for y in range(gradient_height): if y < height: ratio = (gradient_height - y) / gradient_height # Opacidades muito menores para efeito mais sutil if ratio <= 0.2: opacity = int(255 * 0.12 * (ratio / 0.2)) # Muito sutil no início elif ratio <= 0.5: opacity = int(255 * (0.12 + 0.18 * ((ratio - 0.2) / 0.3))) # Crescimento suave else: opacity = int(255 * (0.30 + 0.15 * ((ratio - 0.5) / 0.5))) # Max de 45% current_pixel = gradient.getpixel((0, y)) combined_opacity = min(255, current_pixel[3] + opacity) draw.line([(0, y), (width, y)], fill=(0, 0, 0, combined_opacity)) return gradient class TextSegment: def __init__(self, text: str, is_bold: bool = False, is_italic: bool = False): self.text = text self.is_bold = is_bold self.is_italic = is_italic def parse_text_with_formatting(text: str) -> List[TextSegment]: pattern = r'(<(?:strong|em)>.*?|<(?:strong|em)><(?:strong|em)>.*?)' parts = re.split(pattern, text) segments = [] for part in parts: if not part: continue if match := re.match(r'(.*?)|(.*?)', part): content = match.group(1) or match.group(2) segments.append(TextSegment(content, True, True)) elif match := re.match(r'(.*?)', part): segments.append(TextSegment(match.group(1), True, False)) elif match := re.match(r'(.*?)', part): segments.append(TextSegment(match.group(1), False, True)) else: segments.append(TextSegment(part, False, False)) return segments def get_font(segment: TextSegment, regular_font: ImageFont.FreeTypeFont, bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont, bold_italic_font: ImageFont.FreeTypeFont) -> ImageFont.FreeTypeFont: if segment.is_bold and segment.is_italic: return bold_italic_font elif segment.is_bold: return bold_font elif segment.is_italic: return italic_font return regular_font def wrap_text_with_formatting(segments: List[TextSegment], regular_font: ImageFont.FreeTypeFont, bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont, bold_italic_font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> List[List[TextSegment]]: lines = [] current_line = [] current_width = 0 first_in_line = True for segment in segments: if not segment.text: continue font = get_font(segment, regular_font, bold_font, italic_font, bold_italic_font) words = re.split(r'(\s+)', segment.text) for word in words: if not word: continue if re.match(r'\s+', word): if not first_in_line: current_line.append(TextSegment(word, segment.is_bold, segment.is_italic)) current_width += draw.textlength(word, font=font) continue word_width = draw.textlength(word, font=font) if current_width + word_width <= max_width or first_in_line: current_line.append(TextSegment(word, segment.is_bold, segment.is_italic)) current_width += word_width first_in_line = False else: if current_line: lines.append(current_line) current_line = [TextSegment(word, segment.is_bold, segment.is_italic)] current_width = word_width first_in_line = False if current_line: lines.append(current_line) return lines def wrap_simple_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> List[str]: words = text.split() lines = [] current_line = [] for word in words: test_line = ' '.join(current_line + [word]) if draw.textlength(test_line, font=font) <= max_width or not current_line: current_line.append(word) else: if current_line: lines.append(' '.join(current_line)) current_line = [word] if current_line: lines.append(' '.join(current_line)) return lines def get_responsive_fonts(text: str, regular_font_path: str, bold_font_path: str, italic_font_path: str, bold_italic_font_path: str, max_width: int, max_lines: int, max_font_size: int, min_font_size: int, draw: ImageDraw.Draw) -> Tuple[ImageFont.FreeTypeFont, ImageFont.FreeTypeFont, ImageFont.FreeTypeFont, ImageFont.FreeTypeFont, List[List[TextSegment]], int]: segments = parse_text_with_formatting(text) current_font_size = max_font_size while current_font_size >= min_font_size: try: fonts = [ImageFont.truetype(path, current_font_size) for path in [regular_font_path, bold_font_path, italic_font_path, bold_italic_font_path]] regular_font, bold_font, italic_font, bold_italic_font = fonts except Exception: fonts = [ImageFont.load_default()] * 4 regular_font, bold_font, italic_font, bold_italic_font = fonts lines = wrap_text_with_formatting(segments, regular_font, bold_font, italic_font, bold_italic_font, max_width, draw) if len(lines) <= max_lines: return regular_font, bold_font, italic_font, bold_italic_font, lines, current_font_size current_font_size -= 1 return regular_font, bold_font, italic_font, bold_italic_font, lines, min_font_size def get_responsive_single_font(text: str, font_path: str, max_width: int, max_lines: int, max_font_size: int, min_font_size: int, draw: ImageDraw.Draw, format_text: bool = False) -> Tuple[ImageFont.FreeTypeFont, List[str], int]: formatted_text = f"{text}" if format_text else text current_font_size = max_font_size while current_font_size >= min_font_size: try: font = ImageFont.truetype(font_path, current_font_size) except Exception: font = ImageFont.load_default() lines = wrap_simple_text(formatted_text, font, max_width, draw) if len(lines) <= max_lines: return font, lines, current_font_size current_font_size -= 2 return font, lines, min_font_size def draw_formatted_line(draw: ImageDraw.Draw, line_segments: List[TextSegment], x: int, y: int, regular_font: ImageFont.FreeTypeFont, bold_font: ImageFont.FreeTypeFont, italic_font: ImageFont.FreeTypeFont, bold_italic_font: ImageFont.FreeTypeFont, color: tuple = (255, 255, 255)): current_x = x for segment in line_segments: font = get_font(segment, regular_font, bold_font, italic_font, bold_italic_font) draw.text((current_x, y), segment.text, font=font, fill=color) current_x += draw.textlength(segment.text, font=font) def get_text_color_rgb(text_color: str) -> tuple[int, int, int]: """ Converte o parâmetro text_color para RGB. """ if text_color.lower() == "black": return (0, 0, 0) else: # white por padrão return (255, 255, 255) def get_device_dimensions() -> tuple[int, int]: return (1080, 1350) def add_logo(canvas: Image.Image): try: logo = Image.open("recurve.png").convert("RGBA") logo_resized = logo.resize((121, 23)) 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)) logo_with_opacity.putpixel((x, y), (r, g, b, int(a * 0.42))) canvas.paste(logo_with_opacity, (891, 1274), logo_with_opacity) except Exception as e: print(f"Aviso: Erro ao carregar a logo: {e}") def create_canvas(image_url: Optional[str], text: Optional[str], text_position: str = "bottom", citation: Optional[str] = None, citation_direction: str = "bottom", text_color: str = "white") -> BytesIO: width, height = get_device_dimensions() padding_x, top_padding, citation_text_gap = 60, 60, 15 max_width = width - 2 * padding_x text_rgb = get_text_color_rgb(text_color) # Validação das combinações de posições if text: valid_combinations = { "top": ["text-bottom", "text-top", "bottom"], "bottom": ["text-top", "text-bottom", "top"] } if citation and citation_direction not in valid_combinations.get(text_position, []): raise HTTPException( status_code=400, detail=f"Combinação inválida: text_position='{text_position}' com citation_direction='{citation_direction}'. " f"Para text_position='{text_position}', use citation_direction em: {valid_combinations.get(text_position, [])}" ) canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255)) 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)) # Determinar posições do gradiente e se precisa expandir # Só aplicar gradiente se o texto não for preto gradient_positions = [] needs_expanded_gradient = False if text_color.lower() != "black": if text and text_position.lower() == "bottom": gradient_positions.append("bottom") # Se há citação com text-top, expande o gradiente bottom if citation and citation_direction.lower() == "text-top": needs_expanded_gradient = True elif text and text_position.lower() == "top": gradient_positions.append("top") # Se há citação com text-bottom, expande o gradiente top if citation and citation_direction.lower() == "text-bottom": needs_expanded_gradient = True # Adicionar gradientes para citações em posições fixas if citation and citation_direction.lower() == "top" and "top" not in gradient_positions: gradient_positions.append("top") elif citation and citation_direction.lower() == "bottom" and "bottom" not in gradient_positions: gradient_positions.append("bottom") if gradient_positions: gradient_overlay = create_gradient_overlay(width, height, gradient_positions, needs_expanded_gradient) canvas = Image.alpha_composite(canvas, gradient_overlay) add_logo(canvas) if text or citation: canvas_rgb = canvas.convert("RGB") draw = ImageDraw.Draw(canvas_rgb) font_paths = { 'regular': "fonts/WorkSans-Regular.ttf", 'bold': "fonts/WorkSans-SemiBold.ttf", 'italic': "fonts/WorkSans-Italic.ttf", 'bold_italic': "fonts/WorkSans-SemiBoldItalic.ttf", 'citation': "fonts/AGaramondPro-Semibold.ttf" } text_lines, text_height, citation_lines, citation_height = [], 0, [], 0 if text: try: regular_font, bold_font, italic_font, bold_italic_font, text_lines, font_size = get_responsive_fonts( text, font_paths['regular'], font_paths['bold'], font_paths['italic'], font_paths['bold_italic'], max_width, 5, 35, 15, draw ) text_height = len(text_lines) * int(font_size * 1.2) except Exception: text_lines = [[TextSegment(word, False, False) for word in text.split()]] text_height = 40 if citation: try: citation_font, citation_lines, citation_font_size = get_responsive_single_font( citation, font_paths['citation'], max_width, 3, 60, 30, draw, True ) citation_height = len(citation_lines) * int(citation_font_size * 1.05) except Exception: citation_lines = [citation] citation_height = 40 # Calcular posições respeitando os limites da imagem text_y = citation_y = 0 bottom_limit = 1274 - 50 # Limite inferior (antes da logo) if text and citation: # Calcular espaço total necessário quando há texto e citação total_gap = citation_text_gap if citation_direction in ["text-top", "text-bottom"] else 0 total_content_height = text_height + citation_height + total_gap if citation_direction.lower() == "text-top": # Citação acima do texto if text_position.lower() == "bottom": # Posicionar do bottom para cima text_y = min(bottom_limit - text_height, bottom_limit - text_height) citation_y = text_y - citation_text_gap - citation_height # Verificar se vaza pelo topo if citation_y < top_padding: # Reajustar para caber tudo available_height = bottom_limit - top_padding if total_content_height <= available_height: citation_y = top_padding text_y = citation_y + citation_height + citation_text_gap else: # text top # Posicionar do top para baixo citation_y = top_padding text_y = citation_y + citation_height + citation_text_gap # Verificar se vaza pelo bottom if text_y + text_height > bottom_limit: # Reajustar para caber tudo available_height = bottom_limit - top_padding if total_content_height <= available_height: text_y = bottom_limit - text_height citation_y = text_y - citation_text_gap - citation_height elif citation_direction.lower() == "text-bottom": # Citação abaixo do texto if text_position.lower() == "bottom": # Posicionar do bottom para cima citation_y = bottom_limit - citation_height text_y = citation_y - citation_text_gap - text_height # Verificar se vaza pelo topo if text_y < top_padding: # Reajustar para caber tudo available_height = bottom_limit - top_padding if total_content_height <= available_height: text_y = top_padding citation_y = text_y + text_height + citation_text_gap else: # text top # Posicionar do top para baixo text_y = top_padding citation_y = text_y + text_height + citation_text_gap # Verificar se vaza pelo bottom if citation_y + citation_height > bottom_limit: # Reajustar para caber tudo available_height = bottom_limit - top_padding if total_content_height <= available_height: citation_y = bottom_limit - citation_height text_y = citation_y - citation_text_gap - text_height elif citation_direction.lower() == "top": # Citação no topo, texto na posição original citation_y = top_padding if text_position.lower() == "bottom": text_y = bottom_limit - text_height else: # Evitar sobreposição text_y = max(top_padding + citation_height + citation_text_gap, top_padding) elif citation_direction.lower() == "bottom": # Citação no bottom, texto na posição original citation_y = bottom_limit - citation_height if text_position.lower() == "top": text_y = top_padding else: # Evitar sobreposição text_y = min(bottom_limit - text_height, citation_y - citation_text_gap - text_height) elif text: # Apenas texto, posições fixas originais if text_position.lower() == "bottom": text_y = bottom_limit - text_height else: text_y = top_padding elif citation: # Apenas citação if citation_direction.lower() == "top": citation_y = top_padding elif citation_direction.lower() == "bottom": citation_y = bottom_limit - citation_height # Desenhar citação if citation_lines: line_height = int(citation_font_size * 1.05) if 'citation_font_size' in locals() else 40 for i, line in enumerate(citation_lines): draw.text((padding_x, citation_y + i * line_height), line, font=citation_font if 'citation_font' in locals() else ImageFont.load_default(), fill=text_rgb) # Desenhar texto if text_lines: line_height = int(font_size * 1.2) if 'font_size' in locals() else 40 for i, line in enumerate(text_lines): if 'regular_font' in locals(): draw_formatted_line(draw, line, padding_x, text_y + i * line_height, regular_font, bold_font, italic_font, bold_italic_font, text_rgb) else: draw.text((padding_x, text_y + i * line_height), ' '.join([s.text for s in line]), font=ImageFont.load_default(), fill=text_rgb) canvas = canvas_rgb.convert("RGBA") buffer = BytesIO() canvas.convert("RGB").save(buffer, format="PNG") buffer.seek(0) return buffer def create_cover_canvas(image_url: Optional[str], title: Optional[str], title_position: str = "bottom", text_color: str = "white") -> BytesIO: width, height = get_device_dimensions() padding_x, top_padding = 60, 60 max_width = width - 2 * padding_x text_rgb = get_text_color_rgb(text_color) canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255)) 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)) gradient_positions = [] # Só aplicar gradiente se o texto não for preto if title and text_color.lower() != "black": gradient_positions.append(title_position.lower()) if gradient_positions: gradient_overlay = create_gradient_overlay(width, height, gradient_positions) canvas = Image.alpha_composite(canvas, gradient_overlay) add_logo(canvas) if title: canvas_rgb = canvas.convert("RGB") draw = ImageDraw.Draw(canvas_rgb) try: title_font, title_lines, title_font_size = get_responsive_single_font( title, "fonts/AGaramondPro-Regular.ttf", max_width, 3, 85, 40, draw ) title_line_height = int(title_font_size * 1.2) title_height = len(title_lines) * title_line_height except Exception: title_font = ImageFont.load_default() title_lines = [title] title_line_height = title_height = 50 title_y = (1274 - 50 - title_height) if title_position.lower() == "bottom" else top_padding for i, line in enumerate(title_lines): draw.text((padding_x, title_y + i * title_line_height), line, font=title_font, fill=text_rgb) canvas = canvas_rgb.convert("RGBA") buffer = BytesIO() canvas.convert("RGB").save(buffer, format="PNG") buffer.seek(0) return buffer @router.get("/create/image") def get_news_image( image_url: Optional[str] = Query(None, description="URL da imagem de fundo"), text: Optional[str] = Query(None, description="Texto com suporte a tags "), text_position: str = Query("bottom", description="Posição do texto: 'top' para topo ou 'bottom' para parte inferior"), citation: Optional[str] = Query(None, description="Texto da citação"), citation_direction: str = Query("bottom", description="Posição da citação: 'top', 'bottom' ou 'text-top'"), text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo") ): try: buffer = create_canvas(image_url, text, text_position, citation, citation_direction, 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)}") @router.get("/create/cover/image") def get_cover_image( image_url: Optional[str] = Query(None, description="URL da imagem de fundo"), title: Optional[str] = Query(None, description="Título da capa"), title_position: str = Query("bottom", description="Posição do título: 'top' para topo ou 'bottom' para parte inferior"), text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo") ): try: buffer = create_cover_canvas(image_url, title, title_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)}")