Skip to content

Commit 6e90fbc

Browse files
committed
Add image generation command for posts
1 parent 999e39b commit 6e90fbc

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""
2+
Generate images for posts that don't have one.
3+
4+
Creates gradient backgrounds with title text overlay.
5+
Images are saved to MEDIA_ROOT/posts/
6+
7+
Usage:
8+
python manage.py generate_images
9+
python manage.py generate_images --overwrite # Regenerate all
10+
"""
11+
import hashlib
12+
import os
13+
from io import BytesIO
14+
from django.core.management.base import BaseCommand
15+
from django.core.files.base import ContentFile
16+
from django.conf import settings
17+
from portfolio.models import Post
18+
19+
try:
20+
from PIL import Image, ImageDraw, ImageFont
21+
HAS_PILLOW = True
22+
except ImportError:
23+
HAS_PILLOW = False
24+
25+
26+
class Command(BaseCommand):
27+
help = 'Generate images for posts without images'
28+
29+
# Color schemes based on tags
30+
COLOR_SCHEMES = {
31+
'rust': [(183, 65, 14), (255, 107, 53)],
32+
'python': [(55, 118, 171), (255, 212, 59)],
33+
'java': [(176, 114, 25), (237, 139, 0)],
34+
'javascript': [(247, 223, 30), (50, 51, 48)],
35+
'react': [(97, 218, 251), (32, 35, 42)],
36+
'blockchain': [(247, 147, 26), (20, 21, 26)],
37+
'kubernetes': [(50, 108, 229), (255, 255, 255)],
38+
'aws': [(255, 153, 0), (35, 47, 62)],
39+
'django': [(12, 75, 51), (44, 160, 101)],
40+
'go': [(0, 173, 216), (255, 255, 255)],
41+
'android': [(61, 220, 132), (255, 255, 255)],
42+
'ai': [(138, 43, 226), (255, 105, 180)],
43+
'security': [(220, 20, 60), (25, 25, 25)],
44+
'career': [(70, 130, 180), (255, 255, 255)],
45+
'education': [(34, 139, 34), (255, 255, 255)],
46+
'project': [(255, 69, 0), (255, 215, 0)],
47+
'certifications': [(75, 0, 130), (238, 130, 238)],
48+
'skills': [(0, 128, 128), (255, 255, 255)],
49+
'default': [(99, 102, 241), (168, 85, 247)],
50+
}
51+
52+
def add_arguments(self, parser):
53+
parser.add_argument(
54+
'--overwrite',
55+
action='store_true',
56+
help='Regenerate images even for posts that have one'
57+
)
58+
59+
def get_color_scheme(self, post):
60+
"""Get color scheme based on post tags."""
61+
tags = [t.caption.lower() for t in post.tags.all()]
62+
63+
for tag in tags:
64+
for key in self.COLOR_SCHEMES:
65+
if key in tag:
66+
return self.COLOR_SCHEMES[key]
67+
68+
return self.COLOR_SCHEMES['default']
69+
70+
def wrap_text(self, text, font, max_width, draw):
71+
"""Wrap text to fit within max_width."""
72+
words = text.split()
73+
lines = []
74+
current_line = []
75+
76+
for word in words:
77+
current_line.append(word)
78+
test_line = ' '.join(current_line)
79+
bbox = draw.textbbox((0, 0), test_line, font=font)
80+
width = bbox[2] - bbox[0]
81+
82+
if width > max_width:
83+
if len(current_line) == 1:
84+
lines.append(current_line[0])
85+
current_line = []
86+
else:
87+
current_line.pop()
88+
lines.append(' '.join(current_line))
89+
current_line = [word]
90+
91+
if current_line:
92+
lines.append(' '.join(current_line))
93+
94+
return lines[:3] # Max 3 lines
95+
96+
def create_gradient(self, width, height, color1, color2):
97+
"""Create a diagonal gradient image."""
98+
image = Image.new('RGB', (width, height))
99+
100+
for y in range(height):
101+
for x in range(width):
102+
# Diagonal gradient
103+
ratio = (x + y) / (width + height)
104+
r = int(color1[0] * (1 - ratio) + color2[0] * ratio)
105+
g = int(color1[1] * (1 - ratio) + color2[1] * ratio)
106+
b = int(color1[2] * (1 - ratio) + color2[2] * ratio)
107+
image.putpixel((x, y), (r, g, b))
108+
109+
return image
110+
111+
def generate_image(self, post):
112+
"""Generate an image for a post."""
113+
width, height = 800, 400
114+
color1, color2 = self.get_color_scheme(post)
115+
116+
# Create gradient background
117+
image = self.create_gradient(width, height, color1, color2)
118+
draw = ImageDraw.Draw(image)
119+
120+
# Try to load a font, fall back to default
121+
font_size = 42
122+
try:
123+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
124+
except (IOError, OSError):
125+
try:
126+
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
127+
except (IOError, OSError):
128+
font = ImageFont.load_default()
129+
130+
# Wrap and draw title
131+
title = post.title[:80] # Limit title length
132+
lines = self.wrap_text(title, font, width - 80, draw)
133+
134+
# Calculate vertical position to center text
135+
line_height = font_size + 10
136+
total_height = len(lines) * line_height
137+
y_start = (height - total_height) // 2
138+
139+
# Draw text with shadow for better readability
140+
for i, line in enumerate(lines):
141+
bbox = draw.textbbox((0, 0), line, font=font)
142+
text_width = bbox[2] - bbox[0]
143+
x = (width - text_width) // 2
144+
y = y_start + i * line_height
145+
146+
# Shadow
147+
draw.text((x + 2, y + 2), line, font=font, fill=(0, 0, 0, 128))
148+
# Main text
149+
draw.text((x, y), line, font=font, fill=(255, 255, 255))
150+
151+
# Add subtle tag indicator at bottom
152+
tags = [t.caption for t in post.tags.all()[:3]]
153+
if tags:
154+
tag_text = ' | '.join(tags)
155+
try:
156+
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
157+
except (IOError, OSError):
158+
small_font = font
159+
draw.text((20, height - 30), tag_text.upper(), font=small_font, fill=(255, 255, 255, 180))
160+
161+
return image
162+
163+
def handle(self, *args, **options):
164+
if not HAS_PILLOW:
165+
self.stdout.write(self.style.ERROR('Pillow is required. Run: pip install Pillow'))
166+
return
167+
168+
overwrite = options['overwrite']
169+
170+
if overwrite:
171+
posts = Post.objects.all()
172+
else:
173+
posts = Post.objects.filter(image='')
174+
175+
if not posts.exists():
176+
self.stdout.write('No posts need images.')
177+
return
178+
179+
# Ensure media directory exists
180+
posts_media = os.path.join(settings.MEDIA_ROOT, 'posts')
181+
os.makedirs(posts_media, exist_ok=True)
182+
183+
generated = 0
184+
for post in posts:
185+
try:
186+
image = self.generate_image(post)
187+
188+
# Save to BytesIO
189+
buffer = BytesIO()
190+
image.save(buffer, format='PNG', optimize=True)
191+
buffer.seek(0)
192+
193+
# Generate filename from slug
194+
filename = f"{post.slug[:50]}.png"
195+
196+
# Save to post
197+
post.image.save(filename, ContentFile(buffer.read()), save=True)
198+
199+
generated += 1
200+
self.stdout.write(f' Generated: {post.title[:50]}...')
201+
202+
except Exception as e:
203+
self.stdout.write(self.style.WARNING(f' Failed for {post.title[:30]}: {e}'))
204+
205+
self.stdout.write(self.style.SUCCESS(f'\nDone! Generated {generated} images.'))

0 commit comments

Comments
 (0)