Features: - Add ENABLE_UI and ENABLE_API environment variables for deployment flexibility - Add API URL display textbox with copy button in web interface - Create /api/calculate-auto-values endpoint to centralize sizing logic - Dimension overlay now calls API for accurate calculations instead of duplicating logic Improvements: - Enhance auto-sizing algorithm with aspect-ratio awareness - Improve padding calculation using geometric mean formula - Font detection now uses all available fonts from get_available_fonts() - API URL includes all parameters (font_path, font_size, padding, etc.) UI/UX: - Remove hidden header section and logo copy buttons - API URL textarea wraps and grows vertically to show full URL - Clean up unused code and debug console.log statements Dependencies: - Add gunicorn==21.2.0 to requirements.txt Documentation: - Document environment variables with Docker and Python examples - Add production deployment guidance - Update API documentation with font_path parameter - Add API URL feature to usage instructions Bug fixes: - Change debug=False for production - Remove unused 're' import from app.py
942 lines
40 KiB
Python
942 lines
40 KiB
Python
from flask import Flask, request, jsonify, send_file, render_template
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import io
|
|
import os
|
|
import requests
|
|
from werkzeug.utils import secure_filename
|
|
|
|
try:
|
|
import pytesseract
|
|
TESSERACT_AVAILABLE = True
|
|
except ImportError:
|
|
TESSERACT_AVAILABLE = False
|
|
|
|
app = Flask(__name__)
|
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
|
app.config['UPLOAD_FOLDER'] = 'uploads'
|
|
|
|
# Environment variables for enabling/disabling features
|
|
# Both default to enabled ('true') if not set
|
|
ENABLE_UI = os.getenv('ENABLE_UI', 'true').lower() in ('true', '1', 'yes', 'on')
|
|
ENABLE_API = os.getenv('ENABLE_API', 'true').lower() in ('true', '1', 'yes', 'on')
|
|
|
|
# Ensure upload folder exists
|
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
|
|
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
|
|
|
def allowed_file(filename):
|
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
|
|
def trim_transparent_borders(img):
|
|
"""
|
|
Trim transparent/whitespace borders from image
|
|
Returns the cropped image
|
|
"""
|
|
if img.mode not in ('RGBA', 'LA'):
|
|
# No transparency to trim
|
|
return img
|
|
|
|
# Get the alpha channel
|
|
if img.mode == 'RGBA':
|
|
alpha = img.split()[3]
|
|
else:
|
|
alpha = img.split()[1]
|
|
|
|
# Get the bounding box of non-transparent pixels
|
|
bbox = alpha.getbbox()
|
|
|
|
if bbox:
|
|
return img.crop(bbox)
|
|
else:
|
|
# Image is completely transparent, return as is
|
|
return img
|
|
|
|
def detect_font_from_image(img):
|
|
"""
|
|
Attempt to detect font characteristics from the logo using OCR
|
|
Returns a font path that best matches the detected style
|
|
Uses all available fonts from get_available_fonts()
|
|
"""
|
|
if not TESSERACT_AVAILABLE:
|
|
return None
|
|
|
|
try:
|
|
# Get detailed OCR data including font info
|
|
data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
|
|
|
|
# Get all available fonts
|
|
available_fonts = get_available_fonts()
|
|
if not available_fonts or available_fonts[0]['path'] == 'default':
|
|
return None
|
|
|
|
# Try to detect if logo uses bold/heavy text by checking confidence scores
|
|
# Higher confidence often correlates with bolder, clearer text
|
|
confidences = [conf for conf in data['conf'] if conf != -1]
|
|
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
|
|
|
|
# Categorize fonts by style - prioritize TV-appropriate fonts
|
|
display_fonts = [] # Bebas, Anton, Impact - very bold display fonts
|
|
black_fonts = [] # Black/Heavy weight fonts
|
|
bold_fonts = [] # Bold fonts
|
|
regular_fonts = [] # Regular weight fonts
|
|
|
|
for font in available_fonts:
|
|
font_name_lower = font['name'].lower()
|
|
# TV logos often use display/condensed fonts
|
|
if any(keyword in font_name_lower for keyword in ['bebas', 'anton', 'impact', 'oswald']):
|
|
display_fonts.append(font['path'])
|
|
# Black/Heavy fonts for strong branding
|
|
elif any(keyword in font_name_lower for keyword in ['black', 'heavy', 'extrabold']):
|
|
black_fonts.append(font['path'])
|
|
# Bold fonts
|
|
elif 'bold' in font_name_lower:
|
|
bold_fonts.append(font['path'])
|
|
# Regular fonts
|
|
else:
|
|
regular_fonts.append(font['path'])
|
|
|
|
# Prioritize based on confidence - TV logos typically use bold/display fonts
|
|
if avg_confidence > 70:
|
|
# Very clear text - likely uses display or black fonts
|
|
font_list = display_fonts + black_fonts + bold_fonts + regular_fonts
|
|
elif avg_confidence > 50:
|
|
# Moderately clear - likely bold
|
|
font_list = black_fonts + bold_fonts + display_fonts + regular_fonts
|
|
else:
|
|
# Less clear - might be lighter weight
|
|
font_list = bold_fonts + regular_fonts + black_fonts + display_fonts
|
|
|
|
# Return first available font from the prioritized list
|
|
for font_path in font_list:
|
|
if os.path.exists(font_path):
|
|
return font_path
|
|
|
|
except Exception as e:
|
|
# If OCR fails, fall back to None
|
|
pass
|
|
|
|
return None
|
|
|
|
def get_available_fonts():
|
|
"""
|
|
Get list of available fonts on the system with their paths and display names
|
|
Works on Linux (Docker), macOS, and Windows
|
|
"""
|
|
font_list = [
|
|
# DejaVu fonts (fonts-dejavu package) - Sans-serif
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 'name': 'DejaVu Sans Bold'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 'name': 'DejaVu Sans'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf', 'name': 'DejaVu Sans ExtraLight'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed-Bold.ttf', 'name': 'DejaVu Sans Condensed Bold'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed.ttf', 'name': 'DejaVu Sans Condensed'},
|
|
# DejaVu fonts - Serif
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf', 'name': 'DejaVu Serif Bold'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf', 'name': 'DejaVu Serif'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSerifCondensed-Bold.ttf', 'name': 'DejaVu Serif Condensed Bold'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSerifCondensed.ttf', 'name': 'DejaVu Serif Condensed'},
|
|
# DejaVu fonts - Monospace
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf', 'name': 'DejaVu Sans Mono Bold'},
|
|
{'path': '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', 'name': 'DejaVu Sans Mono'},
|
|
# Liberation fonts (fonts-liberation package) - Sans-serif
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', 'name': 'Liberation Sans Bold'},
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', 'name': 'Liberation Sans'},
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationSansNarrow-Bold.ttf', 'name': 'Liberation Sans Narrow Bold'},
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationSansNarrow-Regular.ttf', 'name': 'Liberation Sans Narrow'},
|
|
# Liberation fonts - Serif
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf', 'name': 'Liberation Serif Bold'},
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf', 'name': 'Liberation Serif'},
|
|
# Liberation fonts - Monospace
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf', 'name': 'Liberation Mono Bold'},
|
|
{'path': '/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf', 'name': 'Liberation Mono'},
|
|
# Liberation2 fonts (fonts-liberation2 package)
|
|
{'path': '/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf', 'name': 'Liberation Sans Bold (v2)'},
|
|
{'path': '/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf', 'name': 'Liberation Sans (v2)'},
|
|
# Noto fonts (fonts-noto package) - Sans-serif
|
|
{'path': '/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf', 'name': 'Noto Sans Bold'},
|
|
{'path': '/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf', 'name': 'Noto Sans'},
|
|
{'path': '/usr/share/fonts/truetype/noto/NotoSans-ExtraBold.ttf', 'name': 'Noto Sans ExtraBold'},
|
|
{'path': '/usr/share/fonts/truetype/noto/NotoSans-Light.ttf', 'name': 'Noto Sans Light'},
|
|
# Noto fonts - Serif
|
|
{'path': '/usr/share/fonts/truetype/noto/NotoSerif-Bold.ttf', 'name': 'Noto Serif Bold'},
|
|
{'path': '/usr/share/fonts/truetype/noto/NotoSerif-Regular.ttf', 'name': 'Noto Serif'},
|
|
# Noto fonts - Monospace
|
|
{'path': '/usr/share/fonts/truetype/noto/NotoMono-Regular.ttf', 'name': 'Noto Mono'},
|
|
# FreeFont (fonts-freefont-ttf package) - Sans-serif
|
|
{'path': '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf', 'name': 'FreeSans Bold'},
|
|
{'path': '/usr/share/fonts/truetype/freefont/FreeSans.ttf', 'name': 'FreeSans'},
|
|
# FreeFont - Serif
|
|
{'path': '/usr/share/fonts/truetype/freefont/FreeSerifBold.ttf', 'name': 'FreeSerif Bold'},
|
|
{'path': '/usr/share/fonts/truetype/freefont/FreeSerif.ttf', 'name': 'FreeSerif'},
|
|
# FreeFont - Monospace
|
|
{'path': '/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf', 'name': 'FreeMono Bold'},
|
|
{'path': '/usr/share/fonts/truetype/freefont/FreeMono.ttf', 'name': 'FreeMono'},
|
|
# Google Fonts - Sans-serif (great for logos)
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Roboto-Black.ttf', 'name': 'Roboto Black'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Roboto-Bold.ttf', 'name': 'Roboto Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Roboto-Regular.ttf', 'name': 'Roboto'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/OpenSans-ExtraBold.ttf', 'name': 'Open Sans ExtraBold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/OpenSans-Bold.ttf', 'name': 'Open Sans Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/OpenSans-Regular.ttf', 'name': 'Open Sans'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Montserrat-Black.ttf', 'name': 'Montserrat Black'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Montserrat-Bold.ttf', 'name': 'Montserrat Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Montserrat-Regular.ttf', 'name': 'Montserrat'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Oswald-Bold.ttf', 'name': 'Oswald Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Oswald-Regular.ttf', 'name': 'Oswald'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Raleway-Black.ttf', 'name': 'Raleway Black'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Raleway-Bold.ttf', 'name': 'Raleway Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Raleway-Regular.ttf', 'name': 'Raleway'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Lato-Black.ttf', 'name': 'Lato Black'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Lato-Bold.ttf', 'name': 'Lato Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Lato-Regular.ttf', 'name': 'Lato'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Poppins-Black.ttf', 'name': 'Poppins Black'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Poppins-Bold.ttf', 'name': 'Poppins Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Poppins-Regular.ttf', 'name': 'Poppins'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Anton-Regular.ttf', 'name': 'Anton'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/BebasNeue-Regular.ttf', 'name': 'Bebas Neue'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/PTSans-Bold.ttf', 'name': 'PT Sans Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/PTSans-Regular.ttf', 'name': 'PT Sans'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Nunito-Black.ttf', 'name': 'Nunito Black'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Nunito-Bold.ttf', 'name': 'Nunito Bold'},
|
|
{'path': '/usr/share/fonts/truetype/google-fonts/Nunito-Regular.ttf', 'name': 'Nunito'},
|
|
|
|
# macOS system fonts (fallback for running outside Docker on Mac)
|
|
{'path': '/System/Library/Fonts/Helvetica.ttc', 'name': 'Helvetica'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Arial.ttf', 'name': 'Arial'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Arial Bold.ttf', 'name': 'Arial Bold'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Arial Black.ttf', 'name': 'Arial Black'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Verdana.ttf', 'name': 'Verdana'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Verdana Bold.ttf', 'name': 'Verdana Bold'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Tahoma.ttf', 'name': 'Tahoma'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Tahoma Bold.ttf', 'name': 'Tahoma Bold'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Impact.ttf', 'name': 'Impact'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Times New Roman.ttf', 'name': 'Times New Roman'},
|
|
{'path': '/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf', 'name': 'Times New Roman Bold'},
|
|
|
|
# Windows system fonts (fallback for running outside Docker on Windows)
|
|
{'path': 'C:\\Windows\\Fonts\\arial.ttf', 'name': 'Arial (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\arialbd.ttf', 'name': 'Arial Bold (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\ariblk.ttf', 'name': 'Arial Black (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\verdana.ttf', 'name': 'Verdana (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\verdanab.ttf', 'name': 'Verdana Bold (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\tahoma.ttf', 'name': 'Tahoma (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\tahomabd.ttf', 'name': 'Tahoma Bold (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\impact.ttf', 'name': 'Impact (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\times.ttf', 'name': 'Times New Roman (Windows)'},
|
|
{'path': 'C:\\Windows\\Fonts\\timesbd.ttf', 'name': 'Times New Roman Bold (Windows)'},
|
|
]
|
|
|
|
# Filter to only fonts that exist on this system
|
|
available = []
|
|
seen_names = set()
|
|
for font in font_list:
|
|
if os.path.exists(font['path']) and font['name'] not in seen_names:
|
|
available.append(font)
|
|
seen_names.add(font['name'])
|
|
|
|
# If no fonts found, return a placeholder that will trigger default font usage
|
|
if not available:
|
|
available.append({'path': 'default', 'name': 'System Default'})
|
|
|
|
return available
|
|
|
|
def get_font(font_size, font_path=None, detected_font_path=None):
|
|
"""
|
|
Get the best available font for text rendering
|
|
Falls back to system default if no fonts are available
|
|
"""
|
|
try:
|
|
# If a specific font was requested and it exists, use it
|
|
if font_path and font_path != 'auto' and font_path != 'default' and os.path.exists(font_path):
|
|
return ImageFont.truetype(font_path, font_size)
|
|
|
|
# If we detected a font from the logo, use it
|
|
if detected_font_path and os.path.exists(detected_font_path):
|
|
return ImageFont.truetype(detected_font_path, font_size)
|
|
|
|
# Otherwise use the first available font
|
|
available_fonts = get_available_fonts()
|
|
if available_fonts and available_fonts[0]['path'] != 'default':
|
|
return ImageFont.truetype(available_fonts[0]['path'], font_size)
|
|
|
|
# Fall back to PIL's default font
|
|
print("Warning: No TrueType fonts found, using PIL default font")
|
|
return ImageFont.load_default()
|
|
except Exception as e:
|
|
print(f"Error loading font: {e}. Using default font.")
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def add_text_to_image(image_path, text, position='below', font_size=None,
|
|
text_color='white', bg_color=None, padding=None, font_path=None):
|
|
"""
|
|
Add text to an image by expanding the canvas
|
|
|
|
Args:
|
|
image_path: Path to the source image
|
|
text: Text to add
|
|
position: Where to add text ('above', 'below', 'left', 'right')
|
|
font_size: Size of the text font (auto if None)
|
|
text_color: Color of the text
|
|
bg_color: Background color for the expanded area (transparent if None and image has alpha)
|
|
padding: Padding around the text (auto if None)
|
|
font_path: Specific font path to use (auto if None)
|
|
|
|
Returns:
|
|
PIL Image object with text added
|
|
"""
|
|
# Load the original image
|
|
img = Image.open(image_path)
|
|
|
|
# Preserve transparency
|
|
has_transparency = img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info)
|
|
|
|
# Convert to RGBA if it has transparency, otherwise RGB
|
|
if has_transparency:
|
|
img = img.convert('RGBA')
|
|
# Trim transparent borders first
|
|
img = trim_transparent_borders(img)
|
|
else:
|
|
img = img.convert('RGB')
|
|
|
|
orig_width, orig_height = img.size
|
|
|
|
# Auto-calculate font size if not provided (based on image dimensions and aspect ratio)
|
|
if font_size is None:
|
|
if position in ['above', 'below']:
|
|
# For horizontal text, base on width with consideration for aspect ratio
|
|
# Wider logos can have larger text
|
|
aspect_ratio = orig_width / orig_height
|
|
if aspect_ratio > 2.5: # Very wide logo
|
|
font_size = int(orig_width * 0.10) # Slightly smaller for very wide logos
|
|
elif aspect_ratio > 1.5: # Wide logo
|
|
font_size = int(orig_width * 0.11)
|
|
else: # Square or portrait logo
|
|
font_size = int(orig_width * 0.14) # Larger for more compact logos
|
|
else: # left or right
|
|
# For vertical text, base on height with consideration for aspect ratio
|
|
aspect_ratio = orig_height / orig_width
|
|
if aspect_ratio > 2.5: # Very tall logo
|
|
font_size = int(orig_height * 0.15) # Smaller for very tall logos
|
|
elif aspect_ratio > 1.5: # Tall logo
|
|
font_size = int(orig_height * 0.18)
|
|
else: # Square or landscape logo
|
|
font_size = int(orig_height * 0.22) # Larger for wider logos
|
|
|
|
# Clamp with smarter bounds based on image size
|
|
min_size = max(20, int(min(orig_width, orig_height) * 0.08)) # At least 8% of smallest dimension
|
|
max_size = min(300, int(max(orig_width, orig_height) * 0.3)) # At most 30% of largest dimension
|
|
font_size = max(min_size, min(font_size, max_size))
|
|
|
|
# Auto-calculate padding if not provided (independent of font size)
|
|
if padding is None:
|
|
if position in ['above', 'below']:
|
|
# Base padding on image width and font size
|
|
# Use geometric mean for balanced scaling
|
|
padding = int((orig_width * font_size) ** 0.5 * 0.12)
|
|
# Ensure reasonable bounds: 15-60 pixels typically
|
|
padding = max(12, min(padding, 60))
|
|
else: # left or right
|
|
# Base padding on image height and font size
|
|
padding = int((orig_height * font_size) ** 0.5 * 0.12)
|
|
# Ensure reasonable bounds
|
|
padding = max(12, min(padding, 60))
|
|
|
|
# Auto-determine background color
|
|
if bg_color is None:
|
|
if has_transparency:
|
|
bg_color = (0, 0, 0, 0) # Transparent
|
|
else:
|
|
bg_color = '#1a1a1a' # Dark gray for non-transparent images
|
|
|
|
# Try to detect font from the logo
|
|
detected_font_path = detect_font_from_image(img)
|
|
|
|
# Get the appropriate font
|
|
font = get_font(font_size, font_path, detected_font_path)
|
|
|
|
# Create a temporary image to measure text size
|
|
temp_img = Image.new('RGB', (1, 1))
|
|
temp_draw = ImageDraw.Draw(temp_img)
|
|
|
|
# Get text bounding box
|
|
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
bbox_offset_x = -bbox[0] # Offset to align text properly
|
|
bbox_offset_y = -bbox[1] # Offset to align text properly
|
|
|
|
# Calculate new image dimensions with directional padding
|
|
# More padding between logo and text, minimal on outer edges (mimic existing padding)
|
|
inner_padding = padding # Padding between logo and text
|
|
outer_padding = padding // 4 # Minimal padding on outer edge
|
|
side_padding = padding // 4 # Minimal padding on perpendicular sides
|
|
|
|
if position in ['above', 'below']:
|
|
text_area_height = text_height + outer_padding + inner_padding
|
|
new_width = max(orig_width, text_width + side_padding * 2)
|
|
new_height = orig_height + text_area_height
|
|
else: # left or right
|
|
text_area_width = text_width + outer_padding + inner_padding
|
|
new_width = orig_width + text_area_width
|
|
new_height = max(orig_height, text_height + side_padding * 2)
|
|
|
|
# Create new image with expanded canvas (preserve transparency)
|
|
if has_transparency:
|
|
new_img = Image.new('RGBA', (new_width, new_height), bg_color)
|
|
else:
|
|
new_img = Image.new('RGB', (new_width, new_height), bg_color)
|
|
|
|
# Calculate positions with directional padding
|
|
if position == 'below':
|
|
# Paste original image at top
|
|
paste_x = (new_width - orig_width) // 2
|
|
new_img.paste(img, (paste_x, 0))
|
|
# Add text below with padding between logo and text, minimal at bottom
|
|
text_x = (new_width - text_width) // 2 + bbox_offset_x
|
|
text_y = orig_height + inner_padding + bbox_offset_y
|
|
elif position == 'above':
|
|
# Paste original image at bottom
|
|
paste_x = (new_width - orig_width) // 2
|
|
new_img.paste(img, (paste_x, text_area_height))
|
|
# Add text above with minimal padding at top, more near logo
|
|
text_x = (new_width - text_width) // 2 + bbox_offset_x
|
|
text_y = outer_padding + bbox_offset_y
|
|
elif position == 'right':
|
|
# Paste original image on left
|
|
paste_y = (new_height - orig_height) // 2
|
|
new_img.paste(img, (0, paste_y))
|
|
# Add text on right with padding between logo and text, minimal on right edge
|
|
text_x = orig_width + inner_padding + bbox_offset_x
|
|
text_y = (new_height - text_height) // 2 + bbox_offset_y
|
|
else: # left
|
|
# Paste original image on right
|
|
paste_y = (new_height - orig_height) // 2
|
|
new_img.paste(img, (text_area_width, paste_y))
|
|
# Add text on left with minimal padding on left edge, more near logo
|
|
text_x = outer_padding + bbox_offset_x
|
|
text_y = (new_height - text_height) // 2 + bbox_offset_y
|
|
|
|
# Draw text on new image
|
|
draw = ImageDraw.Draw(new_img)
|
|
draw.text((text_x, text_y), text, fill=text_color, font=font)
|
|
|
|
# Prepare metadata about what was used
|
|
metadata = {
|
|
'font_size_used': font_size,
|
|
'padding_used': padding,
|
|
'font_name_used': None
|
|
}
|
|
|
|
# Try to get font name
|
|
if font_path and font_path != 'auto' and os.path.exists(font_path):
|
|
metadata['font_name_used'] = os.path.basename(font_path)
|
|
elif detected_font_path and os.path.exists(detected_font_path):
|
|
metadata['font_name_used'] = os.path.basename(detected_font_path)
|
|
else:
|
|
available_fonts = get_available_fonts()
|
|
if available_fonts and available_fonts[0]['path'] != 'default':
|
|
metadata['font_name_used'] = available_fonts[0]['name']
|
|
|
|
return new_img, metadata
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Serve the web interface"""
|
|
if not ENABLE_UI:
|
|
return jsonify({'error': 'Web UI is disabled'}), 403
|
|
return render_template('index.html')
|
|
|
|
@app.route('/api/fonts', methods=['GET'])
|
|
def get_fonts():
|
|
"""Get list of available fonts"""
|
|
if not ENABLE_API:
|
|
return jsonify({'error': 'API is disabled'}), 403
|
|
fonts = get_available_fonts()
|
|
return jsonify({'fonts': fonts})
|
|
|
|
@app.route('/api/tv-logos', methods=['GET'])
|
|
def get_tv_logos():
|
|
"""
|
|
Fetch TV logos from github.com/tv-logo/tv-logos repository
|
|
|
|
Query parameters:
|
|
- search: Search query (fuzzy, case-insensitive)
|
|
- country: Filter by country code
|
|
"""
|
|
if not ENABLE_API:
|
|
return jsonify({'error': 'API is disabled'}), 403
|
|
try:
|
|
# Get query parameters
|
|
search_query = request.args.get('search', '').lower()
|
|
country_filter = request.args.get('country', '').lower()
|
|
|
|
# Use GitHub API to get the repository tree recursively
|
|
# Try 'main' branch (modern default)
|
|
github_api_url = "https://api.github.com/repos/tv-logo/tv-logos/git/trees/main?recursive=1"
|
|
|
|
headers = {'Accept': 'application/vnd.github.v3+json'}
|
|
response = requests.get(github_api_url, headers=headers, timeout=30)
|
|
|
|
# If 'main' doesn't exist, try 'master'
|
|
if response.status_code == 404:
|
|
github_api_url = "https://api.github.com/repos/tv-logo/tv-logos/git/trees/master?recursive=1"
|
|
response = requests.get(github_api_url, headers=headers, timeout=30)
|
|
|
|
response.raise_for_status()
|
|
|
|
tree_data = response.json()
|
|
|
|
# Determine which branch worked
|
|
branch = 'main' if 'main' in github_api_url else 'master'
|
|
|
|
logos = []
|
|
countries = set()
|
|
|
|
# Process all files in the tree
|
|
for item in tree_data.get('tree', []):
|
|
path = item.get('path', '')
|
|
|
|
# Skip if not an image file
|
|
if not path.lower().endswith(('.png', '.jpg', '.jpeg', '.svg')):
|
|
continue
|
|
|
|
# Parse the path - expected format: countries/COUNTRY/logo.png
|
|
path_parts = path.split('/')
|
|
|
|
# Extract country from path like: countries/usa/logo.png
|
|
country = None
|
|
if len(path_parts) >= 3 and path_parts[0] == 'countries':
|
|
country = path_parts[1]
|
|
countries.add(country)
|
|
elif len(path_parts) >= 2:
|
|
# Fallback for other structures - use first directory
|
|
first_dir = path_parts[0]
|
|
if first_dir not in ['.github', 'docs', 'scripts', 'paypal-donate', 'README.md']:
|
|
country = first_dir
|
|
countries.add(country)
|
|
|
|
# Skip if no country identified
|
|
if not country:
|
|
continue
|
|
|
|
# Get filename
|
|
filename = path_parts[-1]
|
|
logo_name = os.path.splitext(filename)[0]
|
|
|
|
# Apply country filter
|
|
if country_filter and country.lower() != country_filter:
|
|
continue
|
|
|
|
# Apply search filter (filename only)
|
|
if search_query and search_query not in logo_name.lower():
|
|
continue
|
|
|
|
# Construct the raw GitHub URL
|
|
raw_url = f"https://raw.githubusercontent.com/tv-logo/tv-logos/{branch}/{path}"
|
|
|
|
logos.append({
|
|
'name': logo_name,
|
|
'country': country,
|
|
'url': raw_url,
|
|
'thumbnail': raw_url,
|
|
'path': path
|
|
})
|
|
|
|
# Sort logos by name
|
|
logos.sort(key=lambda x: x['name'].lower())
|
|
|
|
# Return ALL logos without limits - frontend will handle pagination
|
|
return jsonify({
|
|
'logos': logos,
|
|
'countries': sorted(list(countries)),
|
|
'total': len(logos)
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': f'Failed to fetch logos: {str(e)}'}), 500
|
|
|
|
@app.route('/api/process', methods=['POST'])
|
|
def process_image():
|
|
"""
|
|
API endpoint to process an image
|
|
|
|
Expected form data:
|
|
- image: Image file
|
|
- text: Text to add
|
|
- position: Where to add text (above/below/left/right)
|
|
- font_size: Font size (optional, default 60)
|
|
- text_color: Text color (optional, default white)
|
|
- bg_color: Background color (optional, default #1a1a1a)
|
|
- padding: Padding around text (optional, default 20)
|
|
"""
|
|
if not ENABLE_API:
|
|
return jsonify({'error': 'API is disabled'}), 403
|
|
# Check if image file is present
|
|
if 'image' not in request.files:
|
|
return jsonify({'error': 'No image file provided'}), 400
|
|
|
|
file = request.files['image']
|
|
|
|
if file.filename == '':
|
|
return jsonify({'error': 'No file selected'}), 400
|
|
|
|
if not allowed_file(file.filename):
|
|
return jsonify({'error': 'Invalid file type. Allowed: PNG, JPG, JPEG, GIF, WEBP'}), 400
|
|
|
|
# Get parameters
|
|
text = request.form.get('text', '')
|
|
if not text:
|
|
return jsonify({'error': 'No text provided'}), 400
|
|
|
|
position = request.form.get('position', 'below').lower()
|
|
if position not in ['above', 'below', 'left', 'right']:
|
|
return jsonify({'error': 'Invalid position. Use: above, below, left, or right'}), 400
|
|
|
|
# Handle font size - allow auto/empty for automatic sizing
|
|
font_size_input = request.form.get('font_size', 'auto')
|
|
if font_size_input == '' or font_size_input == 'auto':
|
|
font_size = None
|
|
else:
|
|
try:
|
|
font_size = int(font_size_input)
|
|
except ValueError:
|
|
return jsonify({'error': 'Font size must be a number or "auto"'}), 400
|
|
|
|
# Handle padding - allow auto/empty for automatic padding
|
|
padding_input = request.form.get('padding', 'auto')
|
|
if padding_input == '' or padding_input == 'auto':
|
|
padding = None
|
|
else:
|
|
try:
|
|
padding = int(padding_input)
|
|
except ValueError:
|
|
return jsonify({'error': 'Padding must be a number or "auto"'}), 400
|
|
|
|
text_color = request.form.get('text_color', 'white')
|
|
bg_color = request.form.get('bg_color', 'transparent')
|
|
|
|
# Allow transparent background
|
|
if bg_color == '' or bg_color == 'transparent' or bg_color == 'auto':
|
|
bg_color = None
|
|
|
|
# Get font path if specified
|
|
font_path = request.form.get('font_path', None)
|
|
if font_path == '' or font_path == 'auto':
|
|
font_path = None
|
|
|
|
# Save uploaded file
|
|
filename = secure_filename(file.filename)
|
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
file.save(filepath)
|
|
|
|
try:
|
|
# Process the image
|
|
result_img, metadata = add_text_to_image(
|
|
filepath,
|
|
text,
|
|
position,
|
|
font_size,
|
|
text_color,
|
|
bg_color,
|
|
padding,
|
|
font_path
|
|
)
|
|
|
|
# Save to bytes buffer
|
|
img_io = io.BytesIO()
|
|
|
|
# Determine output format (preserve transparency for PNG)
|
|
output_format = 'PNG'
|
|
if filename.lower().endswith(('.jpg', '.jpeg')):
|
|
output_format = 'JPEG'
|
|
# Convert RGBA to RGB for JPEG
|
|
if result_img.mode == 'RGBA':
|
|
rgb_img = Image.new('RGB', result_img.size, (255, 255, 255))
|
|
rgb_img.paste(result_img, mask=result_img.split()[3])
|
|
result_img = rgb_img
|
|
elif filename.lower().endswith('.gif'):
|
|
output_format = 'GIF'
|
|
elif filename.lower().endswith('.webp'):
|
|
output_format = 'WEBP'
|
|
|
|
# Save with appropriate settings
|
|
if output_format == 'PNG':
|
|
result_img.save(img_io, output_format, optimize=True)
|
|
elif output_format == 'JPEG':
|
|
result_img.save(img_io, output_format, quality=95)
|
|
else:
|
|
result_img.save(img_io, output_format, quality=95)
|
|
img_io.seek(0)
|
|
|
|
# Clean up uploaded file
|
|
os.remove(filepath)
|
|
|
|
# Determine mimetype
|
|
mimetype_map = {
|
|
'PNG': 'image/png',
|
|
'JPEG': 'image/jpeg',
|
|
'GIF': 'image/gif',
|
|
'WEBP': 'image/webp'
|
|
}
|
|
|
|
response = send_file(
|
|
img_io,
|
|
mimetype=mimetype_map.get(output_format, 'image/png'),
|
|
as_attachment=True,
|
|
download_name=f'processed_{filename}'
|
|
)
|
|
response.headers['X-Font-Size-Used'] = str(metadata['font_size_used'])
|
|
response.headers['X-Padding-Used'] = str(metadata['padding_used'])
|
|
if metadata['font_name_used']:
|
|
response.headers['X-Font-Name-Used'] = metadata['font_name_used']
|
|
return response
|
|
|
|
except Exception as e:
|
|
# Clean up on error
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
return jsonify({'error': f'Error processing image: {str(e)}'}), 500
|
|
|
|
@app.route('/api/health', methods=['GET'])
|
|
def health():
|
|
"""Health check endpoint"""
|
|
return jsonify({'status': 'ok'})
|
|
|
|
@app.route('/api/calculate-auto-values', methods=['GET'])
|
|
def calculate_auto_values():
|
|
"""
|
|
Calculate auto font size and padding for given image dimensions
|
|
|
|
Query parameters:
|
|
- width: Original image width (required)
|
|
- height: Original image height (required)
|
|
- position: Text position (optional, default: below)
|
|
- font_size: Font size or 'auto' (optional, default: auto)
|
|
- padding: Padding or 'auto' (optional, default: auto)
|
|
|
|
Returns:
|
|
JSON with calculated font_size and padding values
|
|
"""
|
|
if not ENABLE_API:
|
|
return jsonify({'error': 'API is disabled'}), 403
|
|
try:
|
|
width = int(request.args.get('width'))
|
|
height = int(request.args.get('height'))
|
|
except (TypeError, ValueError):
|
|
return jsonify({'error': 'width and height must be provided as integers'}), 400
|
|
|
|
position = request.args.get('position', 'below').lower()
|
|
if position not in ['above', 'below', 'left', 'right']:
|
|
return jsonify({'error': 'Invalid position. Use: above, below, left, or right'}), 400
|
|
|
|
# Handle font size
|
|
font_size_input = request.args.get('font_size', 'auto')
|
|
if font_size_input == '' or font_size_input == 'auto':
|
|
# Calculate auto font size using same logic as add_text_to_image
|
|
if position in ['above', 'below']:
|
|
aspect_ratio = width / height
|
|
if aspect_ratio > 2.5:
|
|
font_size = int(width * 0.10)
|
|
elif aspect_ratio > 1.5:
|
|
font_size = int(width * 0.11)
|
|
else:
|
|
font_size = int(width * 0.14)
|
|
else: # left or right
|
|
aspect_ratio = height / width
|
|
if aspect_ratio > 2.5:
|
|
font_size = int(height * 0.15)
|
|
elif aspect_ratio > 1.5:
|
|
font_size = int(height * 0.18)
|
|
else:
|
|
font_size = int(height * 0.22)
|
|
|
|
min_size = max(20, int(min(width, height) * 0.08))
|
|
max_size = min(300, int(max(width, height) * 0.3))
|
|
font_size = max(min_size, min(font_size, max_size))
|
|
else:
|
|
try:
|
|
font_size = int(font_size_input)
|
|
except ValueError:
|
|
return jsonify({'error': 'Font size must be a number or "auto"'}), 400
|
|
|
|
# Handle padding
|
|
padding_input = request.args.get('padding', 'auto')
|
|
if padding_input == '' or padding_input == 'auto':
|
|
# Calculate auto padding using same logic as add_text_to_image
|
|
if position in ['above', 'below']:
|
|
padding = int((width * font_size) ** 0.5 * 0.12)
|
|
padding = max(12, min(padding, 60))
|
|
else: # left or right
|
|
padding = int((height * font_size) ** 0.5 * 0.12)
|
|
padding = max(12, min(padding, 60))
|
|
else:
|
|
try:
|
|
padding = int(padding_input)
|
|
except ValueError:
|
|
return jsonify({'error': 'Padding must be a number or "auto"'}), 400
|
|
|
|
return jsonify({
|
|
'font_size': font_size,
|
|
'padding': padding
|
|
})
|
|
|
|
@app.route('/api/image', methods=['GET'])
|
|
def process_image_url():
|
|
"""
|
|
API endpoint to process an image from URL and return the image directly
|
|
|
|
Query parameters:
|
|
- url: Image URL (required)
|
|
- text: Text to add (required)
|
|
- position: Where to add text (optional, default: below)
|
|
- font_size: Font size or 'auto' (optional, default: auto)
|
|
- text_color: Text color (optional, default: white)
|
|
- bg_color: Background color or 'transparent' (optional, default: transparent)
|
|
- padding: Padding or 'auto' (optional, default: auto)
|
|
|
|
Example:
|
|
/api/image?url=https://example.com/logo.png&text=Breaking%20News&position=below
|
|
"""
|
|
if not ENABLE_API:
|
|
return jsonify({'error': 'API is disabled'}), 403
|
|
# Get required parameters
|
|
image_url = request.args.get('url')
|
|
text = request.args.get('text')
|
|
|
|
if not image_url:
|
|
return jsonify({'error': 'Missing required parameter: url'}), 400
|
|
|
|
if not text:
|
|
return jsonify({'error': 'Missing required parameter: text'}), 400
|
|
|
|
# Get optional parameters with defaults
|
|
position = request.args.get('position', 'below').lower()
|
|
if position not in ['above', 'below', 'left', 'right']:
|
|
return jsonify({'error': 'Invalid position. Use: above, below, left, or right'}), 400
|
|
|
|
# Handle font size
|
|
font_size_input = request.args.get('font_size', 'auto')
|
|
if font_size_input == '' or font_size_input == 'auto':
|
|
font_size = None
|
|
else:
|
|
try:
|
|
font_size = int(font_size_input)
|
|
except ValueError:
|
|
return jsonify({'error': 'Font size must be a number or "auto"'}), 400
|
|
|
|
# Handle padding
|
|
padding_input = request.args.get('padding', 'auto')
|
|
if padding_input == '' or padding_input == 'auto':
|
|
padding = None
|
|
else:
|
|
try:
|
|
padding = int(padding_input)
|
|
except ValueError:
|
|
return jsonify({'error': 'Padding must be a number or "auto"'}), 400
|
|
|
|
text_color = request.args.get('text_color', 'white')
|
|
bg_color = request.args.get('bg_color', 'transparent')
|
|
|
|
# Allow transparent background
|
|
if bg_color == '' or bg_color == 'transparent' or bg_color == 'auto':
|
|
bg_color = None
|
|
|
|
# Get font path if specified
|
|
font_path = request.args.get('font_path', None)
|
|
if font_path == '' or font_path == 'auto':
|
|
font_path = None
|
|
|
|
try:
|
|
# Download the image
|
|
response = requests.get(image_url, timeout=10, headers={'User-Agent': 'LogoTextAdder/1.0'})
|
|
response.raise_for_status()
|
|
|
|
# Save to temporary file
|
|
img_bytes = io.BytesIO(response.content)
|
|
|
|
# Determine format from content-type or URL
|
|
content_type = response.headers.get('content-type', '')
|
|
if 'png' in content_type or image_url.lower().endswith('.png'):
|
|
output_format = 'PNG'
|
|
mimetype = 'image/png'
|
|
elif 'jpeg' in content_type or 'jpg' in content_type or image_url.lower().endswith(('.jpg', '.jpeg')):
|
|
output_format = 'JPEG'
|
|
mimetype = 'image/jpeg'
|
|
elif 'gif' in content_type or image_url.lower().endswith('.gif'):
|
|
output_format = 'GIF'
|
|
mimetype = 'image/gif'
|
|
elif 'webp' in content_type or image_url.lower().endswith('.webp'):
|
|
output_format = 'WEBP'
|
|
mimetype = 'image/webp'
|
|
else:
|
|
# Default to PNG
|
|
output_format = 'PNG'
|
|
mimetype = 'image/png'
|
|
|
|
# Create temp file to process
|
|
temp_filename = f"temp_{os.urandom(8).hex()}.{output_format.lower()}"
|
|
temp_filepath = os.path.join(app.config['UPLOAD_FOLDER'], temp_filename)
|
|
|
|
with open(temp_filepath, 'wb') as f:
|
|
f.write(response.content)
|
|
|
|
# Process the image
|
|
result_img, metadata = add_text_to_image(
|
|
temp_filepath,
|
|
text,
|
|
position,
|
|
font_size,
|
|
text_color,
|
|
bg_color,
|
|
padding,
|
|
font_path
|
|
)
|
|
|
|
# Save to bytes buffer
|
|
img_io = io.BytesIO()
|
|
|
|
# Convert RGBA to RGB for JPEG
|
|
if output_format == 'JPEG' and result_img.mode == 'RGBA':
|
|
rgb_img = Image.new('RGB', result_img.size, (255, 255, 255))
|
|
rgb_img.paste(result_img, mask=result_img.split()[3])
|
|
result_img = rgb_img
|
|
|
|
# Save with appropriate settings
|
|
if output_format == 'PNG':
|
|
result_img.save(img_io, output_format, optimize=True)
|
|
elif output_format == 'JPEG':
|
|
result_img.save(img_io, output_format, quality=95)
|
|
else:
|
|
result_img.save(img_io, output_format, quality=95)
|
|
|
|
img_io.seek(0)
|
|
|
|
# Clean up temp file
|
|
os.remove(temp_filepath)
|
|
|
|
# Return image directly
|
|
response = send_file(
|
|
img_io,
|
|
mimetype=mimetype,
|
|
as_attachment=False,
|
|
download_name=f'logo_{text[:20].replace(" ", "_")}.{output_format.lower()}'
|
|
)
|
|
response.headers['X-Font-Size-Used'] = str(metadata['font_size_used'])
|
|
response.headers['X-Padding-Used'] = str(metadata['padding_used'])
|
|
if metadata['font_name_used']:
|
|
response.headers['X-Font-Name-Used'] = metadata['font_name_used']
|
|
return response
|
|
|
|
except requests.RequestException as e:
|
|
return jsonify({'error': f'Failed to download image: {str(e)}'}), 400
|
|
except Exception as e:
|
|
# Clean up on error
|
|
if 'temp_filepath' in locals() and os.path.exists(temp_filepath):
|
|
os.remove(temp_filepath)
|
|
return jsonify({'error': f'Error processing image: {str(e)}'}), 500
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run(debug=False, host='0.0.0.0', port=5001)
|