Files
ALwrity/backend/services/linkedin/carousel/carousel_renderer.py
ajaysi 63a0df2536 feat: LinkedIn LLM alignment - Phase 1-3 complete
Phase 1: Dead Code Cleanup
- Remove GeminiGroundedProvider import and property from linkedin_service.py
- Remove fallback_provider property (gemini_provider imports)
- Fix routers/linkedin.py edit endpoint to use llm_text_gen
- Delete dead LinkedInImageEditor class
- Remove dead _transform_gemini_sources from content_generator.py

Phase 2: Research Infrastructure Alignment
- Add user_id to _conduct_research() for pre-flight validation
- Add validate_exa_research_operations() before Exa/Tavily calls
- Pass user_id to provider.simple_search() for usage tracking
- Inject research content into LLM prompts via _build_research_context()
- Fix Google engine path to fallback to Exa
- Add Exa → Tavily fallback on research failure

Phase 3: Cosmetic Cleanup
- Rename _generate_prompts_with_gemini → _generate_prompts_with_llm
- Rename _build_gemini_prompt → _build_image_prompt
- Rename _parse_gemini_response → _parse_llm_response
- Remove all Gemini references from LinkedIn code (0 remaining)
- Update docstrings and log messages

Additional:
- Research caching using existing ResearchCache
- Shared ExaContentResearchProvider in services/research/
- Persona service uses llm_text_gen instead of gemini_structured_json_response
- LinkedInWriter.tsx ChatMessage → ChatMsg type mapping fix
- RegisterLinkedInActionsEnhanced.tsx content_format_rules typing fix
2026-06-12 18:58:53 +05:30

337 lines
14 KiB
Python

"""
LinkedIn Carousel PDF Renderer
Renders text-based carousel slides into visually appealing PNG images
and composes them into a LinkedIn-compatible PDF document (1.91:1 ratio).
"""
import os
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from reportlab.lib.pagesizes import landscape
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Image as RLImage, PageBreak
logger = logging.getLogger(__name__)
class LinkedInCarouselPDFRenderer:
COLOR_SCHEMES = {
'professional': {
'background_start': (25, 55, 109),
'background_end': (41, 128, 185),
'title_color': (255, 255, 255),
'content_color': (236, 240, 241),
'accent_color': (52, 152, 219),
},
'creative': {
'background_start': (142, 68, 173),
'background_end': (231, 76, 60),
'title_color': (255, 255, 255),
'content_color': (245, 245, 245),
'accent_color': (241, 196, 15),
},
'industry': {
'background_start': (39, 174, 96),
'background_end': (44, 62, 80),
'title_color': (255, 255, 255),
'content_color': (236, 240, 241),
'accent_color': (46, 204, 113),
},
'dark': {
'background_start': (20, 20, 30),
'background_end': (60, 60, 80),
'title_color': (255, 255, 255),
'content_color': (200, 200, 210),
'accent_color': (100, 200, 255),
},
'minimal': {
'background_start': (245, 245, 250),
'background_end': (255, 255, 255),
'title_color': (44, 62, 80),
'content_color': (80, 80, 90),
'accent_color': (52, 152, 219),
},
}
def __init__(self, output_dir: str = None):
self.slide_width = 1200
self.slide_height = 627
self.slide_aspect_ratio = "1.91:1"
self.max_file_size_bytes = 100 * 1024 * 1024
self.max_slides = 300
self.output_dir = output_dir or "data/media/linkedin_carousels"
async def render_carousel_to_pdf(
self,
carousel_data: Dict[str, Any],
color_scheme: str = 'professional',
user_id: Optional[str] = None,
) -> Dict[str, Any]:
start_time = datetime.now()
os.makedirs(self.output_dir, exist_ok=True)
try:
slides = carousel_data.get('slides', [])
if not slides:
return {'success': False, 'error': 'No slides to render'}
title = carousel_data.get('title', 'LinkedIn Carousel')
cover_slide = carousel_data.get('cover_slide')
cta_slide = carousel_data.get('cta_slide')
total_slides = len(slides) + (1 if cover_slide else 0) + (1 if cta_slide else 0)
if total_slides > self.max_slides:
error = f'Too many slides: {total_slides} exceeds max {self.max_slides}'
return {'success': False, 'error': error}
session_id = datetime.now().strftime('%Y%m%d_%H%M%S')
image_paths = []
if cover_slide:
path = self._render_slide(
slide=cover_slide, slide_number=0, session_id=session_id,
color_scheme=color_scheme, is_cover=True, carousel_title=title,
)
if path:
image_paths.append(path)
for i, slide in enumerate(slides):
path = self._render_slide(
slide=slide, slide_number=i + 1, session_id=session_id,
color_scheme=color_scheme, is_cover=False,
)
if path:
image_paths.append(path)
if cta_slide:
path = self._render_slide(
slide=cta_slide, slide_number=len(slides) + 1, session_id=session_id,
color_scheme=color_scheme, is_cta=True,
)
if path:
image_paths.append(path)
if not image_paths:
return {'success': False, 'error': 'No slide images generated'}
pdf_filename = f"linkedin_carousel_{session_id}.pdf"
pdf_path = os.path.join(self.output_dir, pdf_filename)
pdf_bytes = self._compose_pdf(image_paths, pdf_path)
file_size = len(pdf_bytes)
if file_size > self.max_file_size_bytes:
logger.warning("PDF size %.2f MB exceeds max %.2f MB",
file_size / (1024 * 1024), self.max_file_size_bytes / (1024 * 1024))
generation_time = (datetime.now() - start_time).total_seconds()
return {
'success': True,
'pdf_bytes': pdf_bytes,
'pdf_path': pdf_path,
'metadata': {
'slide_count': len(image_paths),
'generation_time': generation_time,
'file_size': file_size,
'file_size_mb': round(file_size / (1024 * 1024), 2),
'dimensions': f'{self.slide_width}x{self.slide_height}',
'aspect_ratio': self.slide_aspect_ratio,
}
}
except Exception as e:
logger.error("Error rendering carousel PDF: %s", str(e))
return {'success': False, 'error': f'Carousel PDF rendering failed: {str(e)}'}
def _render_slide(
self,
slide: Dict[str, Any],
slide_number: int,
session_id: str,
color_scheme: str = 'professional',
is_cover: bool = False,
is_cta: bool = False,
carousel_title: str = '',
) -> Optional[str]:
try:
colors = self.COLOR_SCHEMES.get(color_scheme, self.COLOR_SCHEMES['professional'])
img = Image.new('RGB', (self.slide_width, self.slide_height))
draw = ImageDraw.Draw(img)
self._draw_gradient(draw, colors)
draw.rectangle([0, self.slide_height - 6, self.slide_width, self.slide_height], fill=colors['accent_color'])
if is_cover:
self._draw_centered_text(draw, carousel_title or slide.get('title', ''),
(self.slide_width // 2, 180), colors['title_color'],
font_size=42, max_width=self.slide_width - 160)
subtitle = slide.get('content', '')
if subtitle:
self._draw_centered_text(draw, subtitle,
(self.slide_width // 2, 320), colors['content_color'],
font_size=24, max_width=self.slide_width - 200, max_lines=3)
self._draw_centered_text(draw, "Swipe to explore →",
(self.slide_width // 2, 480), colors['accent_color'],
font_size=18)
elif is_cta:
self._draw_text(draw, slide.get('title', ''), (60, 160), colors['title_color'],
font_size=36, max_width=self.slide_width - 120, max_lines=2)
content = slide.get('content', '')
if content:
self._draw_text(draw, content, (60, 260), colors['content_color'],
font_size=22, max_width=self.slide_width - 120, max_lines=6)
btn_x, btn_y = self.slide_width // 2 - 200, 440
draw.rounded_rectangle([btn_x, btn_y, btn_x + 400, btn_y + 55], radius=27, fill=colors['accent_color'])
self._draw_centered_text(draw, "Share Your Thoughts →",
(self.slide_width // 2, btn_y + 27), (255, 255, 255), font_size=22)
else:
self._draw_text(draw, str(slide_number),
(self.slide_width - 50, 20), colors['accent_color'], font_size=16)
title = slide.get('title', '')
if title:
self._draw_text(draw, title, (60, 50), colors['title_color'],
font_size=30, max_width=self.slide_width - 120, max_lines=2)
content = slide.get('content', '')
if content:
self._draw_text(draw, content, (60, 145), colors['content_color'],
font_size=20, max_width=self.slide_width - 120, max_lines=10)
visual_elements = slide.get('visual_elements', [])
if visual_elements:
self._draw_visual_elements(draw, visual_elements, colors)
filename = f"slide_{session_id}_{slide_number:03d}.png"
filepath = os.path.join(self.output_dir, filename)
img.save(filepath, 'PNG', optimize=True)
return filepath
except Exception as e:
logger.error("Error rendering slide %d: %s", slide_number, str(e))
return None
def _draw_gradient(self, draw: ImageDraw.Draw, colors: Dict):
sr, sg, sb = colors['background_start']
er, eg, eb = colors['background_end']
for y in range(self.slide_height):
t = y / self.slide_height
draw.line([(0, y), (self.slide_width, y)],
fill=(int(sr + (er - sr) * t), int(sg + (eg - sg) * t), int(sb + (eb - sb) * t)))
def _draw_text(self, draw: ImageDraw.Draw, text: str, position: tuple, color: tuple,
font_size: int = 20, max_width: int = None, max_lines: int = None, bold: bool = False):
font = self._get_font(font_size, bold)
x, y = position
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bb = draw.textbbox((0, 0), test_line, font=font)
tw = bb[2] - bb[0]
if max_width and tw > max_width and current_line:
lines.append(current_line)
if max_lines and len(lines) >= max_lines:
lines[-1] = lines[-1][:-3] + "..."
break
current_line = word
else:
current_line = test_line
if current_line and (not max_lines or len(lines) < max_lines):
lines.append(current_line)
line_height = int(font_size * 1.4)
for i, line in enumerate(lines):
draw.text((x, y + i * line_height), line, fill=color, font=font)
def _draw_centered_text(self, draw: ImageDraw.Draw, text: str, center: tuple, color: tuple,
font_size: int = 20, max_width: int = None, max_lines: int = None, bold: bool = False):
font = self._get_font(font_size, bold)
cx, cy = center
words = text.split()
lines = []
current_line = ""
for word in words:
test_line = f"{current_line} {word}".strip()
bb = draw.textbbox((0, 0), test_line, font=font)
tw = bb[2] - bb[0]
if max_width and tw > max_width and current_line:
lines.append(current_line)
if max_lines and len(lines) >= max_lines:
lines[-1] = lines[-1][:-3] + "..."
break
current_line = word
else:
current_line = test_line
if current_line and (not max_lines or len(lines) < max_lines):
lines.append(current_line)
line_height = int(font_size * 1.4)
total_height = len(lines) * line_height
start_y = cy - total_height // 2
for i, line in enumerate(lines):
bb = draw.textbbox((0, 0), line, font=font)
tw = bb[2] - bb[0]
x = cx - tw // 2
draw.text((x, start_y + i * line_height), line, fill=color, font=font)
def _draw_visual_elements(self, draw: ImageDraw.Draw, elements: List[str], colors: Dict):
y_start = self.slide_height - 60
x_start = 60
for i, element in enumerate(elements[:4]):
cx = x_start + i * 280
draw.ellipse([cx, y_start, cx + 12, y_start + 12], fill=colors['accent_color'])
font = self._get_font(12, False)
draw.text((cx + 20, y_start - 2), element[:25], fill=colors['content_color'], font=font)
def _get_font(self, size: int, bold: bool = False):
try:
return ImageFont.truetype("arialbd.ttf" if bold else "arial.ttf", size)
except (IOError, OSError):
try:
return ImageFont.truetype("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", size)
except (IOError, OSError):
return ImageFont.load_default()
def _compose_pdf(self, image_paths: List[str], output_path: str) -> bytes:
pw = self.slide_width
ph = self.slide_height
# Leave 1pt margin to avoid ReportLab frame size issues
m = 1
iw = pw - 2 * m
ih = ph - 2 * m
from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate
from reportlab.lib.pagesizes import landscape
frame = Frame(m, m, iw, ih, id="slide_frame",
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0)
template = PageTemplate(id="slide", frames=[frame], pagesize=(pw, ph))
doc = BaseDocTemplate(output_path, pagesize=(pw, ph))
doc.addPageTemplates([template])
story = []
for i, img_path in enumerate(image_paths):
story.append(RLImage(img_path, width=iw, height=ih))
if i < len(image_paths) - 1:
story.append(PageBreak())
doc.build(story)
with open(output_path, 'rb') as f:
return f.read()