habulaj's picture
Upload 13 files
4ffe0a9 verified
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
@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 <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)}")
@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)}")