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 | |
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)}") |