Spaces:
Paused
Paused
| 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)><(?: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'<strong><em>(.*?)</em></strong>|<em><strong>(.*?)</strong></em>', part): | |
| content = match.group(1) or match.group(2) | |
| segments.append(TextSegment(content, True, True)) | |
| elif match := re.match(r'<strong>(.*?)</strong>', part): | |
| segments.append(TextSegment(match.group(1), True, False)) | |
| elif match := re.match(r'<em>(.*?)</em>', 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 | |
| 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 <strong>"), | |
| 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)}") | |
| 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)}") |