Initial Commit

This commit is contained in:
2026-01-11 10:27:06 -05:00
commit 04cffb964e
9 changed files with 1576 additions and 0 deletions

42
.dockerignore Normal file
View File

@@ -0,0 +1,42 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
*.egg-info/
.eggs/
*.egg
# Project specific
uploads/
*.png
*.jpg
*.jpeg
*.gif
*.webp
# Git
.git/
.gitignore
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Documentation
README.md
# Docker
Dockerfile
.dockerignore

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Flask
instance/
.webassets-cache
# Uploads folder
uploads/
*.png
*.jpg
*.jpeg
*.gif
*.webp
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
FROM python:3.11-slim
# Install system dependencies for Pillow and Tesseract
RUN apt-get update && apt-get install -y \
tesseract-ocr \
libtesseract-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev \
fonts-dejavu \
fonts-dejavu-core \
fonts-dejavu-extra \
fonts-liberation \
fonts-liberation2 \
fontconfig \
&& fc-cache -f -v \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files
COPY app.py .
COPY templates/ templates/
# Create uploads directory
RUN mkdir -p uploads
# Expose port
EXPOSE 5001
# Set environment variables
ENV PYTHONUNBUFFERED=1
# Run the application
CMD ["python", "app.py"]

234
README.md Normal file
View File

@@ -0,0 +1,234 @@
# 📺 Logo Text Adder
A Python web application for adding custom text to TV station and network logos. Upload a logo, specify your text and positioning preferences, and download the enhanced image with expanded canvas.
## Features
- 🎨 **Web Interface**: User-friendly web UI for easy logo processing
- 🔌 **REST API**: Programmatic access for automation
- 📐 **Flexible Positioning**: Add text above, below, left, or right of logos
- 🎨 **Customization**: Adjust font size, text color, background color, and padding
- 📦 **Multiple Formats**: Supports PNG, JPG, JPEG, GIF, and WEBP
-**Instant Preview**: See results immediately in the browser
## Quick Start
### Using Docker Compose (Recommended)
1. **Create a `docker-compose.yml` file:**
```yaml
services:
logo-txt:
image: ghcr.io/sethwv/logo-txt:latest
ports:
- "5001:5001"
restart: unless-stopped
```
2. **Start the application:**
```bash
docker compose up -d
```
3. **Access the application:**
Open your browser and navigate to `http://localhost:5001`
4. **Stop the application:**
```bash
docker compose down
```
### Using Docker Run
1. **Pull and run the container:**
```bash
docker run -d -p 5001:5001 --name logo-txt ghcr.io/sethwv/logo-txt:latest
```
2. **Access the application:**
Open your browser and navigate to `http://localhost:5001`
3. **Stop the container:**
```bash
docker stop logo-txt
docker rm logo-txt
```
## Usage
### Web Interface
1. Open your browser and navigate to `http://localhost:5001`
2. Upload your logo image or enter an image URL
3. Enter the text you want to add
4. Choose the position (above, below, left, or right)
5. Optionally adjust advanced settings:
- Font size (default: auto - scales based on image size)
- Padding (default: auto - scales based on font size)
- Text color (default: white)
- Background color (default: transparent)
6. Click "Generate Logo"
7. Preview and download your enhanced logo
### API Usage
The application provides a REST API endpoint for programmatic access.
#### Process Image from URL
`GET /api/image`
Process an image from a URL using query parameters. The image is returned directly.
##### Query Parameters
- `url` (string, required): URL of the image to process
- `text` (string, required): Text to add to the image
- `position` (string, optional): Where to place the text (`above`, `below`, `left`, or `right`) - default: `below`
- `font_size` (integer or "auto", optional): Font size in pixels, or "auto" for automatic sizing - default: `auto`
- `text_color` (string, optional): Text color - default: `white`
- `bg_color` (string, optional): Background color or "transparent" - default: `transparent`
- `padding` (integer or "auto", optional): Padding in pixels or "auto" - default: `auto`
##### Example URLs
**Basic usage:**
```
http://localhost:5001/api/image?url=https://example.com/logo.png&text=Breaking%20News
```
**With all parameters:**
```
http://localhost:5001/api/image?url=https://example.com/logo.png&text=Breaking%20News&position=below&font_size=auto&text_color=white&bg_color=transparent&padding=auto
```
##### Example with cURL
```bash
curl "http://localhost:5001/api/image?url=https://example.com/logo.png&text=Breaking%20News&position=below" \
--output processed_logo.png
```
##### Example with Python
```python
import requests
url = "http://localhost:5001/api/image"
params = {
"url": "https://example.com/logo.png",
"text": "Breaking News",
"position": "below",
"font_size": "auto",
"text_color": "white",
"bg_color": "transparent"
}
response = requests.get(url, params=params)
if response.status_code == 200:
with open("processed_logo.png", "wb") as f:
f.write(response.content)
print("Image processed successfully!")
else:
print(f"Error: {response.json()}")
```
##### Example with JavaScript
```javascript
const params = new URLSearchParams({
url: 'https://example.com/logo.png',
text: 'Breaking News',
position: 'below',
font_size: 'auto',
text_color: 'white',
bg_color: 'transparent'
});
const response = await fetch(`http://localhost:5001/api/image?${params}`);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Use the URL to display or download the image
} else {
const error = await response.json();
console.error('Error:', error);
}
```
## Project Structure
```
logo-txt/
├── app.py # Flask application and image processing logic
├── templates/
│ └── index.html # Web interface
├── uploads/ # Temporary storage for uploaded images (auto-created)
├── requirements.txt # Python dependencies
├── Dockerfile # Docker image configuration
├── docker-compose.yml # Docker Compose configuration
└── README.md # This file
```
## How It Works
1. **Image Upload**: User uploads a logo through the web interface or API
2. **Canvas Expansion**: The application calculates the required canvas size based on text dimensions and position
3. **Text Rendering**: Text is rendered with the specified styling on the expanded canvas
4. **Image Composition**: The original logo and text are composited onto the new canvas
5. **Output**: The processed image is returned to the user for download
## Configuration
### Docker Configuration
The application runs on port 5001 by default. To change the port, modify the `docker-compose.yml` file:
```yaml
ports:
- "8080:5001" # Maps host port 8080 to container port 5001
```
### Application Settings
You can modify these settings in `app.py`:
- `MAX_CONTENT_LENGTH`: Maximum upload file size (default: 16MB)
- `UPLOAD_FOLDER`: Temporary folder for uploads (default: 'uploads')
- Server host and port in the `if __name__ == '__main__'` block
## Troubleshooting
### Port Already in Use
If port 5001 is already in use, change the port mapping in `docker-compose.yml`:
```yaml
ports:
- "8080:5001" # Use port 8080 instead
```
Then access the application at `http://localhost:8080`
### Font Issues
The Docker image includes DejaVu and Liberation fonts by default. If you need additional fonts, you can:
1. Modify the Dockerfile to install additional font packages
2. Mount a font directory as a volume in `docker-compose.yml`
### View Container Logs
```bash
docker compose logs -f
```
## License
This project is provided as-is for personal and commercial use.
## Contributing
Feel free to submit issues, fork the repository, and create pull requests for any improvements.

548
app.py Normal file
View File

@@ -0,0 +1,548 @@
from flask import Flask, request, jsonify, send_file, render_template
from PIL import Image, ImageDraw, ImageFont
import io
import os
import re
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'
# 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
"""
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)
# Look for font characteristics in detected text
# Common bold/heavy fonts used in TV logos
bold_fonts = [
'/System/Library/Fonts/Supplemental/Arial Black.ttf',
'/System/Library/Fonts/Supplemental/Impact.ttf',
'/System/Library/Fonts/Supplemental/Arial Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'C:\\Windows\\Fonts\\ariblk.ttf',
'C:\\Windows\\Fonts\\impact.ttf',
'C:\\Windows\\Fonts\\arialbd.ttf',
]
# Default to clean sans-serif fonts
regular_fonts = [
'/System/Library/Fonts/Helvetica.ttc',
'/System/Library/Fonts/Supplemental/Arial.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'C:\\Windows\\Fonts\\arial.ttf',
]
# 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
# If high confidence detected text, likely uses bold fonts
font_list = bold_fonts if avg_confidence > 60 else regular_fonts
# Return first available font
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_font(font_size, detected_font_path=None):
"""
Get the best available font for text rendering
"""
try:
# 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 try common fonts (prioritize bold/heavy fonts)
font_paths = [
# macOS paths
'/System/Library/Fonts/Supplemental/Arial Black.ttf',
'/System/Library/Fonts/Supplemental/Impact.ttf',
'/System/Library/Fonts/Supplemental/Arial Bold.ttf',
'/System/Library/Fonts/Helvetica.ttc',
# Linux paths (DejaVu)
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
# Linux paths (Liberation - free alternative to Arial)
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
# Windows paths
'C:\\Windows\\Fonts\\ariblk.ttf',
'C:\\Windows\\Fonts\\impact.ttf',
'C:\\Windows\\Fonts\\arialbd.ttf',
]
for font_path in font_paths:
if os.path.exists(font_path):
return ImageFont.truetype(font_path, font_size)
return ImageFont.load_default()
except:
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):
"""
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)
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)
if font_size is None:
if position in ['above', 'below']:
font_size = int(orig_width * 0.12) # 12% of image width
else: # left or right
font_size = int(orig_height * 0.20) # 20% of image height (larger for vertical text)
font_size = max(30, min(font_size, 250)) # Clamp between 30 and 250
# Auto-calculate padding if not provided
if padding is None:
padding = int(font_size * 0.25) # 25% of font size
# 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, 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)
return new_img
@app.route('/')
def index():
"""Serve the web interface"""
return render_template('index.html')
@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)
"""
# 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
# 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 = add_text_to_image(
filepath,
text,
position,
font_size,
text_color,
bg_color,
padding
)
# 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'
}
return send_file(
img_io,
mimetype=mimetype_map.get(output_format, 'image/png'),
as_attachment=True,
download_name=f'processed_{filename}'
)
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/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
"""
# 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
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 = add_text_to_image(
temp_filepath,
text,
position,
font_size,
text_color,
bg_color,
padding
)
# 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
return send_file(
img_io,
mimetype=mimetype,
as_attachment=False,
download_name=f'logo_{text[:20].replace(" ", "_")}.{output_format.lower()}'
)
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=True, host='0.0.0.0', port=5001)

29
docker-build-push.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -e
# Configuration
# IMAGE_NAME="git.seth.services/seth/logo-txt"
IMAGE_NAME="ghcr.io/sethwv/logo-txt"
VERSION="${1:-latest}"
echo "Building Docker image for amd64 architecture..."
docker buildx build \
--platform linux/amd64 \
-t ${IMAGE_NAME}:${VERSION} \
-t ${IMAGE_NAME}:latest \
--load \
.
echo "Testing the image..."
docker run --rm ${IMAGE_NAME}:${VERSION} python -c "import flask; import PIL; print('Image OK')"
echo "Pushing image to Container Registry..."
docker push ${IMAGE_NAME}:${VERSION}
docker push ${IMAGE_NAME}:latest
echo "Done! Image pushed to:"
echo " ${IMAGE_NAME}:${VERSION}"
echo " ${IMAGE_NAME}:latest"
echo ""
echo "To run the container:"
echo " docker run -p 5001:5001 ${IMAGE_NAME}:latest"

6
docker-compose.yml Normal file
View File

@@ -0,0 +1,6 @@
services:
logo-txt:
image: ghcr.io/sethwv/logo-txt:latest
ports:
- "5001:5001"
restart: unless-stopped

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
Flask==3.1.0
Pillow==11.0.0
Werkzeug==3.1.3
pytesseract==0.3.13
requests==2.32.3

619
templates/index.html Normal file
View File

@@ -0,0 +1,619 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logo Text Adder</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 1.1em;
}
.content {
padding: 40px;
}
.form-group {
margin-bottom: 25px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 0.95em;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 12px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus {
outline: none;
border-color: #667eea;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
width: 100%;
}
.file-input-wrapper input[type="file"] {
position: absolute;
left: -9999px;
}
.file-input-label {
display: block;
padding: 15px;
background: #f8f9fa;
border: 2px dashed #667eea;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.file-input-label:hover {
background: #e8eaf6;
border-color: #764ba2;
}
.file-input-label.has-file {
background: #e8f5e9;
border-color: #4caf50;
}
.color-input-group {
display: flex;
gap: 10px;
align-items: center;
}
.color-input-group input[type="text"] {
flex: 1;
}
input[type="color"] {
width: 60px;
height: 45px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
}
.position-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.position-option {
position: relative;
}
.position-option input[type="radio"] {
position: absolute;
opacity: 0;
}
.position-option label {
display: block;
padding: 15px;
background: #f8f9fa;
border: 2px solid #e0e0e0;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin: 0;
}
.position-option input[type="radio"]:checked + label {
background: #667eea;
color: white;
border-color: #667eea;
}
.position-option label:hover {
border-color: #667eea;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 30px;
border: none;
border-radius: 8px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
width: 100%;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.preview-section {
margin-top: 30px;
padding-top: 30px;
border-top: 2px solid #e0e0e0;
}
.preview-section h2 {
margin-bottom: 20px;
color: #333;
}
.preview-image {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
background-color: white;
}
.loading {
text-align: center;
padding: 20px;
color: #667eea;
font-weight: 600;
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #c62828;
}
.success {
background: #e8f5e9;
color: #2e7d32;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #2e7d32;
}
.download-btn {
display: inline-block;
margin-top: 15px;
padding: 12px 25px;
background: #4caf50;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
}
.download-btn:hover {
background: #45a049;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(76, 175, 80, 0.4);
}
.advanced-options {
margin-top: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.advanced-options h3 {
margin-bottom: 15px;
color: #555;
font-size: 1.1em;
}
.two-column {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.input-tabs {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.tab-btn {
flex: 1;
padding: 10px 20px;
background: #f8f9fa;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
color: #666;
transition: all 0.3s;
}
.tab-btn:hover {
border-color: #667eea;
}
.tab-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
@media (max-width: 600px) {
.two-column, .position-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 1.8em;
}
.content {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📺 Logo Text Adder</h1>
<p>Add custom text to your TV station and network logos</p>
</div>
<div class="content">
<div id="message"></div>
<form id="logoForm">
<div class="form-group">
<label>Logo Image</label>
<div class="input-tabs">
<button type="button" class="tab-btn active" data-tab="file">📁 Upload File</button>
<button type="button" class="tab-btn" data-tab="url">🔗 Image URL</button>
</div>
<div id="fileTab" class="tab-content active">
<div class="file-input-wrapper">
<input type="file" id="imageFile" accept="image/*">
<label for="imageFile" class="file-input-label" id="fileLabel">
<span>📁 Choose an image file (PNG, JPG, GIF, WEBP)</span>
</label>
</div>
</div>
<div id="urlTab" class="tab-content">
<input type="text" id="imageUrl" placeholder="https://example.com/logo.png">
</div>
</div>
<div class="form-group">
<label for="text">Text to Add</label>
<input type="text" id="text" placeholder="Enter your text here..." required>
</div>
<div class="form-group">
<label>Text Position</label>
<div class="position-grid">
<div class="position-option">
<input type="radio" id="above" name="position" value="above">
<label for="above">⬆️ Above</label>
</div>
<div class="position-option">
<input type="radio" id="below" name="position" value="below" checked>
<label for="below">⬇️ Below</label>
</div>
<div class="position-option">
<input type="radio" id="left" name="position" value="left">
<label for="left">⬅️ Left</label>
</div>
<div class="position-option">
<input type="radio" id="right" name="position" value="right">
<label for="right">➡️ Right</label>
</div>
</div>
</div>
<div class="advanced-options">
<h3>⚙️ Advanced Options</h3>
<div class="two-column">
<div class="form-group">
<label for="fontSize">Font Size</label>
<input type="text" id="fontSize" value="auto" placeholder="auto or number">
<small style="color: #666; font-size: 0.85em;">Leave as "auto" for automatic sizing</small>
</div>
<div class="form-group">
<label for="padding">Padding</label>
<input type="text" id="padding" value="auto" placeholder="auto or number">
<small style="color: #666; font-size: 0.85em;">Leave as "auto" for automatic padding</small>
</div>
</div>
<div class="form-group">
<label for="textColor">Text Color</label>
<div class="color-input-group">
<input type="text" id="textColor" value="white" placeholder="white or #ffffff">
<input type="color" id="textColorPicker" value="#ffffff">
</div>
</div>
<div class="form-group">
<label for="bgColor">Background Color</label>
<div class="color-input-group">
<input type="text" id="bgColor" value="transparent" placeholder="transparent or #1a1a1a">
<input type="color" id="bgColorPicker" value="#1a1a1a">
</div>
<small style="color: #666; font-size: 0.85em;">Use "transparent" to preserve logo transparency</small>
</div>
</div>
<button type="submit" class="btn" id="submitBtn">
✨ Generate Logo
</button>
</form>
<div id="preview" class="preview-section" style="display: none;">
<h2>Preview</h2>
<img id="previewImage" class="preview-image" alt="Processed logo">
<br>
<a href="#" id="downloadBtn" class="download-btn" download>⬇️ Download Image</a>
</div>
</div>
</div>
<script>
const form = document.getElementById('logoForm');
const imageFile = document.getElementById('imageFile');
const imageUrl = document.getElementById('imageUrl');
const fileLabel = document.getElementById('fileLabel');
const submitBtn = document.getElementById('submitBtn');
const messageDiv = document.getElementById('message');
const preview = document.getElementById('preview');
const previewImage = document.getElementById('previewImage');
const downloadBtn = document.getElementById('downloadBtn');
const tabBtns = document.querySelectorAll('.tab-btn');
const fileTab = document.getElementById('fileTab');
const urlTab = document.getElementById('urlTab');
let activeInputMode = 'file';
// Tab switching
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
activeInputMode = tab;
// Update button states
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update tab content
fileTab.classList.remove('active');
urlTab.classList.remove('active');
if (tab === 'file') {
fileTab.classList.add('active');
} else {
urlTab.classList.add('active');
}
});
});
// Color picker sync
const textColorInput = document.getElementById('textColor');
const textColorPicker = document.getElementById('textColorPicker');
const bgColorInput = document.getElementById('bgColor');
const bgColorPicker = document.getElementById('bgColorPicker');
textColorPicker.addEventListener('input', (e) => {
textColorInput.value = e.target.value;
});
textColorInput.addEventListener('input', (e) => {
if (e.target.value.startsWith('#')) {
textColorPicker.value = e.target.value;
}
});
bgColorPicker.addEventListener('input', (e) => {
bgColorInput.value = e.target.value;
});
bgColorInput.addEventListener('input', (e) => {
if (e.target.value.startsWith('#') && e.target.value.length === 7) {
bgColorPicker.value = e.target.value;
}
});
// File input handling
imageFile.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
fileLabel.classList.add('has-file');
fileLabel.innerHTML = `<span>✅ ${file.name}</span>`;
} else {
fileLabel.classList.remove('has-file');
fileLabel.innerHTML = '<span>📁 Choose an image file (PNG, JPG, GIF, WEBP)</span>';
}
});
function showMessage(message, type) {
messageDiv.innerHTML = `<div class="${type}">${message}</div>`;
messageDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearMessage() {
messageDiv.innerHTML = '';
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearMessage();
const text = document.getElementById('text').value;
if (!text.trim()) {
showMessage('Please enter some text', 'error');
return;
}
const position = document.querySelector('input[name="position"]:checked').value;
const fontSize = document.getElementById('fontSize').value;
const padding = document.getElementById('padding').value;
const textColor = textColorInput.value;
const bgColor = bgColorInput.value;
submitBtn.disabled = true;
submitBtn.textContent = '⏳ Processing...';
preview.style.display = 'none';
try {
let response;
if (activeInputMode === 'url') {
// Use URL-based API
const url = imageUrl.value.trim();
if (!url) {
showMessage('Please enter an image URL', 'error');
submitBtn.disabled = false;
submitBtn.textContent = '✨ Generate Logo';
return;
}
const params = new URLSearchParams({
url: url,
text: text,
position: position,
font_size: fontSize,
padding: padding,
text_color: textColor,
bg_color: bgColor
});
response = await fetch(`/api/image?${params}`);
} else {
// Use file upload API
const file = imageFile.files[0];
if (!file) {
showMessage('Please select an image file', 'error');
submitBtn.disabled = false;
submitBtn.textContent = '✨ Generate Logo';
return;
}
const formData = new FormData();
formData.append('image', file);
formData.append('text', text);
formData.append('position', position);
formData.append('font_size', fontSize);
formData.append('padding', padding);
formData.append('text_color', textColor);
formData.append('bg_color', bgColor);
response = await fetch('/api/process', {
method: 'POST',
body: formData
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to process image');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
previewImage.src = url;
downloadBtn.href = url;
downloadBtn.download = `logo_${text.replace(/\s+/g, '_')}.png`;
preview.style.display = 'block';
showMessage('✅ Logo generated successfully!', 'success');
preview.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (error) {
showMessage(`❌ Error: ${error.message}`, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '✨ Generate Logo';
}
});
</script>
</body>
</html>