|
|
|
|
|
"""
|
|
Advanced image card generator with markdown and emoji support
|
|
"""
|
|
import math
|
|
import random
|
|
import os
|
|
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
import emoji
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import List, Tuple, Optional, Dict
|
|
from datetime import datetime
|
|
|
|
|
|
@dataclass
|
|
class TextStyle:
|
|
"""文本样式定义"""
|
|
font_name: str = 'regular'
|
|
font_size: int = 30
|
|
indent: int = 0
|
|
line_spacing: int = 15
|
|
is_title: bool = False
|
|
is_category: bool = False
|
|
keep_with_next: bool = False
|
|
|
|
|
|
@dataclass
|
|
class TextSegment:
|
|
"""文本片段定义"""
|
|
text: str
|
|
style: TextStyle
|
|
original_text: str = ''
|
|
|
|
|
|
@dataclass
|
|
class ProcessedLine:
|
|
"""处理后的行信息"""
|
|
text: str
|
|
style: TextStyle
|
|
height: int = 0
|
|
line_count: int = 1
|
|
|
|
|
|
class FontManager:
|
|
"""字体管理器"""
|
|
|
|
def __init__(self, font_paths: Dict[str, str]):
|
|
self.fonts = {}
|
|
self.font_paths = font_paths
|
|
self._initialize_fonts()
|
|
|
|
def _initialize_fonts(self):
|
|
"""初始化基础字体"""
|
|
sizes = [30, 35, 40]
|
|
for size in sizes:
|
|
self.fonts[f'regular_{size}'] = ImageFont.truetype(self.font_paths['regular'], size)
|
|
self.fonts[f'bold_{size}'] = ImageFont.truetype(self.font_paths['bold'], size)
|
|
|
|
self.fonts['emoji_30'] = ImageFont.truetype(self.font_paths['emoji'], 30)
|
|
|
|
def get_font(self, style: TextStyle) -> ImageFont.FreeTypeFont:
|
|
"""获取对应样式的字体"""
|
|
if style.font_name == 'emoji':
|
|
return self.fonts['emoji_30']
|
|
|
|
base_name = 'bold' if style.font_name == 'bold' or style.is_title or style.is_category else 'regular'
|
|
font_key = f'{base_name}_{style.font_size}'
|
|
|
|
if font_key not in self.fonts:
|
|
|
|
self.fonts[font_key] = ImageFont.truetype(
|
|
self.font_paths['bold' if base_name == 'bold' else 'regular'],
|
|
style.font_size
|
|
)
|
|
|
|
return self.fonts[font_key]
|
|
|
|
|
|
def get_gradient_styles() -> List[Dict[str, tuple]]:
|
|
"""
|
|
获取精心设计的背景渐变样式
|
|
"""
|
|
return [
|
|
|
|
|
|
{
|
|
"start_color": (246, 246, 248),
|
|
"end_color": (250, 250, 252)
|
|
},
|
|
{
|
|
"start_color": (245, 245, 247),
|
|
"end_color": (248, 248, 250)
|
|
},
|
|
|
|
{
|
|
"start_color": (191, 203, 255),
|
|
"end_color": (255, 203, 237)
|
|
},
|
|
{
|
|
"start_color": (168, 225, 255),
|
|
"end_color": (203, 255, 242)
|
|
},
|
|
|
|
|
|
{
|
|
"start_color": (255, 209, 209),
|
|
"end_color": (243, 209, 255)
|
|
},
|
|
{
|
|
"start_color": (255, 230, 209),
|
|
"end_color": (255, 209, 247)
|
|
},
|
|
|
|
|
|
{
|
|
"start_color": (213, 255, 219),
|
|
"end_color": (209, 247, 255)
|
|
},
|
|
{
|
|
"start_color": (255, 236, 209),
|
|
"end_color": (255, 209, 216)
|
|
},
|
|
|
|
|
|
{
|
|
"start_color": (237, 240, 245),
|
|
"end_color": (245, 237, 245)
|
|
},
|
|
{
|
|
"start_color": (240, 245, 255),
|
|
"end_color": (245, 240, 245)
|
|
},
|
|
|
|
|
|
{
|
|
"start_color": (255, 223, 242),
|
|
"end_color": (242, 223, 255)
|
|
},
|
|
{
|
|
"start_color": (223, 255, 247),
|
|
"end_color": (223, 242, 255)
|
|
},
|
|
|
|
|
|
{
|
|
"start_color": (255, 192, 203),
|
|
"end_color": (192, 203, 255)
|
|
},
|
|
{
|
|
"start_color": (192, 255, 238),
|
|
"end_color": (238, 192, 255)
|
|
},
|
|
|
|
|
|
{
|
|
"start_color": (230, 240, 255),
|
|
"end_color": (255, 240, 245)
|
|
},
|
|
{
|
|
"start_color": (245, 240, 255),
|
|
"end_color": (240, 255, 240)
|
|
},
|
|
|
|
|
|
{
|
|
"start_color": (255, 235, 235),
|
|
"end_color": (235, 235, 255)
|
|
},
|
|
{
|
|
"start_color": (235, 255, 235),
|
|
"end_color": (255, 235, 245)
|
|
}
|
|
]
|
|
|
|
|
|
def create_gradient_background(width: int, height: int) -> Image.Image:
|
|
"""创建渐变背景 - 从左上到右下的对角线渐变"""
|
|
gradient_styles = get_gradient_styles()
|
|
style = random.choice(gradient_styles)
|
|
start_color = style["start_color"]
|
|
end_color = style["end_color"]
|
|
|
|
|
|
base = Image.new('RGB', (width, height))
|
|
draw = ImageDraw.Draw(base)
|
|
|
|
|
|
for y in range(height):
|
|
for x in range(width):
|
|
|
|
|
|
position = (x + y) / (width + height)
|
|
|
|
|
|
r = int(start_color[0] * (1 - position) + end_color[0] * position)
|
|
g = int(start_color[1] * (1 - position) + end_color[1] * position)
|
|
b = int(start_color[2] * (1 - position) + end_color[2] * position)
|
|
|
|
|
|
draw.point((x, y), fill=(r, g, b))
|
|
|
|
return base
|
|
|
|
|
|
def get_theme_colors() -> Tuple[tuple, str, bool]:
|
|
"""获取主题颜色配置"""
|
|
current_hour = datetime.now().hour
|
|
current_minute = datetime.now().minute
|
|
|
|
if (current_hour == 8 and current_minute >= 30) or (9 <= current_hour < 19):
|
|
use_dark = random.random() < 0.1
|
|
else:
|
|
use_dark = True
|
|
|
|
if use_dark:
|
|
|
|
return ((50, 50, 50, 128), "#FFFFFF", True)
|
|
else:
|
|
|
|
return ((255, 255, 255, 128), "#000000", False)
|
|
|
|
|
|
def create_rounded_rectangle(image: Image.Image, x: int, y: int, w: int, h: int, radius: int, bg_color: tuple):
|
|
"""创建圆角毛玻璃矩形"""
|
|
|
|
rectangle = Image.new('RGBA', (int(w), int(h)), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(rectangle)
|
|
|
|
|
|
draw.rounded_rectangle(
|
|
[(0, 0), (int(w), int(h))],
|
|
radius,
|
|
fill=bg_color
|
|
)
|
|
|
|
|
|
image.paste(rectangle, (int(x), int(y)), rectangle)
|
|
|
|
|
|
def round_corner_image(image: Image.Image, radius: int) -> Image.Image:
|
|
"""将图片转为圆角"""
|
|
|
|
circle = Image.new('L', (radius * 2, radius * 2), 0)
|
|
draw = ImageDraw.Draw(circle)
|
|
draw.ellipse((0, 0, radius * 2, radius * 2), fill=255)
|
|
|
|
|
|
mask = Image.new('L', image.size, 255)
|
|
|
|
|
|
mask.paste(circle.crop((0, 0, radius, radius)), (0, 0))
|
|
mask.paste(circle.crop((radius, 0, radius * 2, radius)), (image.width - radius, 0))
|
|
mask.paste(circle.crop((0, radius, radius, radius * 2)), (0, image.height - radius))
|
|
mask.paste(circle.crop((radius, radius, radius * 2, radius * 2)),
|
|
(image.width - radius, image.height - radius))
|
|
|
|
|
|
output = Image.new('RGBA', image.size, (0, 0, 0, 0))
|
|
|
|
|
|
output.paste(image, (0, 0))
|
|
output.putalpha(mask)
|
|
|
|
return output
|
|
|
|
|
|
def add_title_image(background: Image.Image, title_image_path: str, rect_x: int, rect_y: int, rect_width: int) -> int:
|
|
"""添加标题图片"""
|
|
try:
|
|
with Image.open(title_image_path) as title_img:
|
|
|
|
if title_img.mode != 'RGBA':
|
|
title_img = title_img.convert('RGBA')
|
|
|
|
|
|
target_width = rect_width - 40
|
|
|
|
|
|
aspect_ratio = title_img.height / title_img.width
|
|
target_height = int(target_width * aspect_ratio)
|
|
|
|
|
|
resized_img = title_img.resize((int(target_width), target_height), Image.Resampling.LANCZOS)
|
|
|
|
|
|
rounded_img = round_corner_image(resized_img, radius=20)
|
|
|
|
|
|
x = rect_x + 20
|
|
y = rect_y + 20
|
|
|
|
|
|
background.paste(rounded_img, (x, y), rounded_img)
|
|
|
|
return y + target_height + 20
|
|
except Exception as e:
|
|
print(f"Error loading title image: {e}")
|
|
return rect_y + 30
|
|
|
|
|
|
class MarkdownParser:
|
|
"""Markdown解析器"""
|
|
|
|
def __init__(self):
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.segments = []
|
|
self.current_section = None
|
|
|
|
def parse(self, text: str) -> List[TextSegment]:
|
|
"""解析整个文本"""
|
|
self.reset()
|
|
segments = []
|
|
lines = text.splitlines()
|
|
|
|
for i, line in enumerate(lines):
|
|
line = line.strip()
|
|
if not line:
|
|
|
|
next_has_content = False
|
|
for next_line in lines[i + 1:]:
|
|
if next_line.strip():
|
|
next_has_content = True
|
|
break
|
|
if next_has_content:
|
|
style = TextStyle(
|
|
line_spacing=20 if segments and segments[-1].style.is_title else 15
|
|
)
|
|
segments.append(TextSegment(text='', style=style))
|
|
continue
|
|
|
|
|
|
line_segments = self.parse_line(line)
|
|
segments.extend(line_segments)
|
|
|
|
|
|
if i < len(lines) - 1:
|
|
has_next_content = False
|
|
for next_line in lines[i + 1:]:
|
|
if next_line.strip():
|
|
has_next_content = True
|
|
break
|
|
if has_next_content:
|
|
style = line_segments[-1].style
|
|
segments.append(TextSegment(text='', style=TextStyle(line_spacing=style.line_spacing)))
|
|
|
|
|
|
if segments:
|
|
signature = TextSegment(
|
|
text=" —By 99i",
|
|
style=TextStyle(font_name='regular', indent=0, line_spacing=0)
|
|
)
|
|
segments.append(signature)
|
|
|
|
return segments
|
|
|
|
def is_category_title(self, text: str) -> bool:
|
|
"""判断是否为分类标题"""
|
|
return text.strip() in ['国内要闻', '国际动态']
|
|
|
|
def process_title_marks(self, text: str) -> str:
|
|
"""处理标题标记"""
|
|
|
|
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
|
|
|
text = text.replace(':', ':')
|
|
return text
|
|
|
|
def split_number_and_content(self, text: str) -> Tuple[str, str]:
|
|
"""分离序号和内容"""
|
|
match = re.match(r'(\d+)\.\s*(.+)', text)
|
|
if match:
|
|
return match.group(1), match.group(2)
|
|
return '', text
|
|
|
|
def split_title_and_content(self, text: str) -> Tuple[str, str]:
|
|
"""分离标题和内容"""
|
|
parts = text.split(':', 1)
|
|
if len(parts) == 2:
|
|
return parts[0] + ':', parts[1].strip()
|
|
return text, ''
|
|
|
|
def parse_line(self, text: str) -> List[TextSegment]:
|
|
"""解析单行文本"""
|
|
if not text.strip():
|
|
return [TextSegment(text='', style=TextStyle())]
|
|
|
|
|
|
if text.startswith('# '):
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=40,
|
|
is_title=True,
|
|
indent=0
|
|
)
|
|
return [TextSegment(text=text[2:].strip(), style=style)]
|
|
|
|
|
|
if text.startswith('## '):
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=35,
|
|
is_title=True,
|
|
line_spacing=25,
|
|
indent=0
|
|
)
|
|
self.current_section = text[3:].strip()
|
|
return [TextSegment(text=self.current_section, style=style)]
|
|
|
|
|
|
if self.is_category_title(text):
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=35,
|
|
is_category=True,
|
|
line_spacing=25,
|
|
indent=0
|
|
)
|
|
return [TextSegment(text=text.strip(), style=style)]
|
|
|
|
|
|
if text.strip() and emoji.is_emoji(text[0]):
|
|
|
|
content = text.strip()
|
|
if '**' in content:
|
|
content = content.replace('**', '')
|
|
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=40,
|
|
is_title=True,
|
|
line_spacing=25,
|
|
indent=0
|
|
)
|
|
return [TextSegment(text=content, style=style)]
|
|
|
|
|
|
number, content = self.split_number_and_content(text)
|
|
if number:
|
|
content = self.process_title_marks(content)
|
|
title, body = self.split_title_and_content(content)
|
|
segments = []
|
|
|
|
title_style = TextStyle(
|
|
font_name='bold',
|
|
indent=0,
|
|
is_title=True,
|
|
line_spacing=15 if body else 20
|
|
)
|
|
segments.append(TextSegment(
|
|
text=f"{number}. {title}",
|
|
style=title_style
|
|
))
|
|
|
|
if body:
|
|
content_style = TextStyle(
|
|
font_name='regular',
|
|
indent=40,
|
|
line_spacing=20
|
|
)
|
|
segments.append(TextSegment(
|
|
text=body,
|
|
style=content_style
|
|
))
|
|
return segments
|
|
|
|
|
|
if text.strip().startswith('-'):
|
|
style = TextStyle(
|
|
font_name='regular',
|
|
indent=40,
|
|
line_spacing=15
|
|
)
|
|
return [TextSegment(text=text.strip(), style=style)]
|
|
|
|
|
|
style = TextStyle(
|
|
font_name='regular',
|
|
indent=40 if self.current_section else 0,
|
|
line_spacing=15
|
|
)
|
|
|
|
return [TextSegment(text=text.strip(), style=style)]
|
|
|
|
|
|
def parse_line(self, text: str) -> List[TextSegment]:
|
|
"""解析单行文本"""
|
|
if not text.strip():
|
|
return [TextSegment(text='', style=TextStyle())]
|
|
|
|
|
|
if text.startswith('# '):
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=40,
|
|
is_title=True,
|
|
indent=0
|
|
)
|
|
return [TextSegment(text=text[2:].strip(), style=style)]
|
|
|
|
|
|
if text.startswith('## '):
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=35,
|
|
is_title=True,
|
|
line_spacing=25,
|
|
indent=0
|
|
)
|
|
self.current_section = text[3:].strip()
|
|
return [TextSegment(text=self.current_section, style=style)]
|
|
|
|
|
|
if self.is_category_title(text):
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=35,
|
|
is_category=True,
|
|
line_spacing=25,
|
|
indent=0
|
|
)
|
|
return [TextSegment(text=text.strip(), style=style)]
|
|
|
|
|
|
|
|
if text.strip() and emoji.is_emoji(text[0]):
|
|
|
|
content = text.strip()
|
|
if '**' in content:
|
|
content = content.replace('**', '')
|
|
|
|
style = TextStyle(
|
|
font_name='bold',
|
|
font_size=40,
|
|
is_title=True,
|
|
line_spacing=25,
|
|
indent=0
|
|
)
|
|
return [TextSegment(text=content, style=style)]
|
|
|
|
|
|
number, content = self.split_number_and_content(text)
|
|
if number:
|
|
content = self.process_title_marks(content)
|
|
title, body = self.split_title_and_content(content)
|
|
segments = []
|
|
|
|
title_style = TextStyle(
|
|
font_name='bold',
|
|
indent=0,
|
|
is_title=True,
|
|
line_spacing=15 if body else 20
|
|
)
|
|
segments.append(TextSegment(
|
|
text=f"{number}. {title}",
|
|
style=title_style
|
|
))
|
|
|
|
if body:
|
|
content_style = TextStyle(
|
|
font_name='regular',
|
|
indent=40,
|
|
line_spacing=20
|
|
)
|
|
segments.append(TextSegment(
|
|
text=body,
|
|
style=content_style
|
|
))
|
|
return segments
|
|
|
|
|
|
if text.strip().startswith('-'):
|
|
style = TextStyle(
|
|
font_name='regular',
|
|
indent=40,
|
|
line_spacing=15
|
|
)
|
|
return [TextSegment(text=text.strip(), style=style)]
|
|
|
|
|
|
style = TextStyle(
|
|
font_name='regular',
|
|
indent=40 if self.current_section else 0,
|
|
line_spacing=15
|
|
)
|
|
|
|
return [TextSegment(text=text.strip(), style=style)]
|
|
|
|
def parse(self, text: str) -> List[TextSegment]:
|
|
"""解析整个文本"""
|
|
self.reset()
|
|
segments = []
|
|
lines = text.splitlines()
|
|
|
|
for i, line in enumerate(lines):
|
|
line = line.strip()
|
|
if not line:
|
|
|
|
next_has_content = False
|
|
for next_line in lines[i + 1:]:
|
|
if next_line.strip():
|
|
next_has_content = True
|
|
break
|
|
if next_has_content:
|
|
style = TextStyle(
|
|
line_spacing=20 if segments and segments[-1].style.is_title else 15
|
|
)
|
|
segments.append(TextSegment(text='', style=style))
|
|
continue
|
|
|
|
|
|
line_segments = self.parse_line(line)
|
|
segments.extend(line_segments)
|
|
|
|
|
|
if i < len(lines) - 1:
|
|
has_next_content = False
|
|
for next_line in lines[i + 1:]:
|
|
if next_line.strip():
|
|
has_next_content = True
|
|
break
|
|
if has_next_content:
|
|
style = line_segments[-1].style
|
|
segments.append(TextSegment(text='', style=TextStyle(line_spacing=style.line_spacing)))
|
|
|
|
|
|
if segments:
|
|
signature = TextSegment(
|
|
text=" —By 嫣然",
|
|
style=TextStyle(font_name='regular', indent=0, line_spacing=0)
|
|
)
|
|
segments.append(signature)
|
|
|
|
return segments
|
|
|
|
|
|
class TextRenderer:
|
|
"""文本渲染器"""
|
|
|
|
def __init__(self, font_manager: FontManager, max_width: int):
|
|
self.font_manager = font_manager
|
|
self.max_width = max_width
|
|
self.temp_image = Image.new('RGBA', (2000, 100))
|
|
self.temp_draw = ImageDraw.Draw(self.temp_image)
|
|
|
|
def measure_text(self, text: str, font: ImageFont.FreeTypeFont,
|
|
emoji_font: Optional[ImageFont.FreeTypeFont] = None) -> Tuple[int, int]:
|
|
"""测量文本尺寸,考虑emoji"""
|
|
total_width = 0
|
|
max_height = 0
|
|
|
|
for char in text:
|
|
if emoji.is_emoji(char) and emoji_font:
|
|
bbox = self.temp_draw.textbbox((0, 0), char, font=emoji_font)
|
|
width = bbox[2] - bbox[0]
|
|
height = bbox[3] - bbox[1]
|
|
else:
|
|
bbox = self.temp_draw.textbbox((0, 0), char, font=font)
|
|
width = bbox[2] - bbox[0]
|
|
height = bbox[3] - bbox[1]
|
|
|
|
total_width += width
|
|
max_height = max(max_height, height)
|
|
|
|
return total_width, max_height
|
|
|
|
def draw_text_with_emoji(self, draw: ImageDraw.ImageDraw, pos: Tuple[int, int], text: str,
|
|
font: ImageFont.FreeTypeFont, emoji_font: ImageFont.FreeTypeFont,
|
|
fill: str = "white") -> int:
|
|
"""绘制包含emoji的文本,返回绘制宽度"""
|
|
x, y = pos
|
|
total_width = 0
|
|
|
|
for char in text:
|
|
if emoji.is_emoji(char):
|
|
|
|
bbox = draw.textbbox((x, y), char, font=emoji_font)
|
|
draw.text((x, y), char, font=emoji_font, fill=fill)
|
|
char_width = bbox[2] - bbox[0]
|
|
else:
|
|
|
|
bbox = draw.textbbox((x, y), char, font=font)
|
|
draw.text((x, y), char, font=font, fill=fill)
|
|
char_width = bbox[2] - bbox[0]
|
|
|
|
x += char_width
|
|
total_width += char_width
|
|
|
|
return total_width
|
|
|
|
def calculate_height(self, processed_lines: List[ProcessedLine]) -> int:
|
|
"""计算总高度,确保不在最后添加额外间距"""
|
|
total_height = 0
|
|
prev_line = None
|
|
|
|
for i, line in enumerate(processed_lines):
|
|
if not line.text.strip():
|
|
|
|
if i < len(processed_lines) - 1 and any(l.text.strip() for l in processed_lines[i + 1:]):
|
|
if prev_line:
|
|
total_height += prev_line.style.line_spacing
|
|
continue
|
|
|
|
|
|
line_height = line.height * line.line_count
|
|
|
|
|
|
if prev_line and i < len(processed_lines) - 1:
|
|
if prev_line.style.is_category:
|
|
total_height += 30
|
|
elif prev_line.style.is_title and not line.style.is_title:
|
|
total_height += 20
|
|
else:
|
|
total_height += line.style.line_spacing
|
|
|
|
total_height += line_height
|
|
prev_line = line
|
|
|
|
return total_height
|
|
|
|
def split_text_to_lines(self, segment: TextSegment, available_width: int) -> List[ProcessedLine]:
|
|
"""将文本分割成合适宽度的行,支持emoji"""
|
|
if not segment.text.strip():
|
|
return [ProcessedLine(text='', style=segment.style, height=0, line_count=1)]
|
|
|
|
font = self.font_manager.get_font(segment.style)
|
|
emoji_font = self.font_manager.fonts['emoji_30']
|
|
words = []
|
|
current_word = ''
|
|
processed_lines = []
|
|
|
|
|
|
for char in segment.text:
|
|
if emoji.is_emoji(char):
|
|
if current_word:
|
|
words.append(current_word)
|
|
current_word = ''
|
|
words.append(char)
|
|
elif char in [' ', ',', '。', ':', '、', '!', '?', ';']:
|
|
if current_word:
|
|
words.append(current_word)
|
|
words.append(char)
|
|
current_word = ''
|
|
else:
|
|
if ord(char) > 0x4e00:
|
|
if current_word:
|
|
words.append(current_word)
|
|
current_word = ''
|
|
words.append(char)
|
|
else:
|
|
current_word += char
|
|
|
|
if current_word:
|
|
words.append(current_word)
|
|
|
|
current_line = ''
|
|
line_height = 0
|
|
|
|
for word in words:
|
|
test_line = current_line + word
|
|
width, height = self.measure_text(test_line, font, emoji_font)
|
|
line_height = max(line_height, height)
|
|
|
|
if width <= available_width:
|
|
current_line = test_line
|
|
else:
|
|
if current_line:
|
|
processed_lines.append(ProcessedLine(
|
|
text=current_line,
|
|
style=segment.style,
|
|
height=line_height,
|
|
line_count=1
|
|
))
|
|
current_line = word
|
|
|
|
if current_line:
|
|
processed_lines.append(ProcessedLine(
|
|
text=current_line,
|
|
style=segment.style,
|
|
height=line_height,
|
|
line_count=1
|
|
))
|
|
|
|
return processed_lines
|
|
|
|
|
|
def compress_image(image_path: str, output_path: str, max_size: int = 3145728):
|
|
"""
|
|
Compress an image to ensure it's under a certain file size.
|
|
|
|
:param image_path: The path to the image to be compressed.
|
|
:param output_path: The path where the compressed image will be saved.
|
|
:param max_size: The maximum file size in bytes (default is 3MB).
|
|
"""
|
|
|
|
with Image.open(image_path) as img:
|
|
|
|
if img.mode != 'RGB':
|
|
img = img.convert('RGB')
|
|
|
|
|
|
quality = 95
|
|
|
|
|
|
while True:
|
|
|
|
img.save(output_path, "PNG", optimize=True, compress_level=0)
|
|
|
|
|
|
if os.path.getsize(output_path) <= max_size:
|
|
break
|
|
|
|
|
|
quality -= 5
|
|
if quality < 10:
|
|
break
|
|
|
|
|
|
if quality < 10:
|
|
print("The image could not be compressed enough to meet the size requirements.")
|
|
|
|
|
|
def generate_image(text: str, output_path: str, title_image: Optional[str] = None):
|
|
"""生成图片主函数 - 修复彩色emoji渲染"""
|
|
try:
|
|
width = 720
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
font_paths = {
|
|
'regular': os.path.join(current_dir, "msyh.ttc"),
|
|
'bold': os.path.join(current_dir, "msyhbd.ttc"),
|
|
'emoji': os.path.join(current_dir, "TwitterColorEmoji.ttf")
|
|
}
|
|
|
|
|
|
for font_type, path in font_paths.items():
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(f"Font file not found: {path}")
|
|
|
|
|
|
font_manager = FontManager(font_paths)
|
|
rect_width = width - 80
|
|
max_content_width = rect_width - 80
|
|
parser = MarkdownParser()
|
|
renderer = TextRenderer(font_manager, max_content_width)
|
|
|
|
|
|
segments = parser.parse(text)
|
|
processed_lines = []
|
|
|
|
for segment in segments:
|
|
available_width = max_content_width - segment.style.indent
|
|
if segment.text.strip():
|
|
lines = renderer.split_text_to_lines(segment, available_width)
|
|
processed_lines.extend(lines)
|
|
else:
|
|
processed_lines.append(ProcessedLine(
|
|
text='',
|
|
style=segment.style,
|
|
height=0,
|
|
line_count=1
|
|
))
|
|
|
|
|
|
title_height = 0
|
|
if title_image:
|
|
try:
|
|
with Image.open(title_image) as img:
|
|
aspect_ratio = img.height / img.width
|
|
title_height = int((rect_width - 40) * aspect_ratio) + 40
|
|
except Exception as e:
|
|
print(f"Title image processing error: {e}")
|
|
|
|
content_height = renderer.calculate_height(processed_lines)
|
|
rect_height = content_height + title_height
|
|
rect_x = (width - rect_width) // 2
|
|
rect_y = 40
|
|
total_height = rect_height + 80
|
|
|
|
|
|
background = create_gradient_background(width, total_height)
|
|
draw = ImageDraw.Draw(background)
|
|
|
|
|
|
background_color, text_color, is_dark_theme = get_theme_colors()
|
|
if len(background_color) == 3:
|
|
background_color = background_color + (128,)
|
|
|
|
|
|
create_rounded_rectangle(
|
|
background, rect_x, rect_y, rect_width, rect_height,
|
|
radius=30, bg_color=background_color
|
|
)
|
|
|
|
|
|
current_y = rect_y + 30
|
|
if title_image:
|
|
current_y = add_title_image(background, title_image, rect_x, rect_y, rect_width)
|
|
|
|
|
|
for i, line in enumerate(processed_lines):
|
|
if not line.text.strip():
|
|
if i < len(processed_lines) - 1 and any(l.text.strip() for l in processed_lines[i + 1:]):
|
|
current_y += line.style.line_spacing
|
|
continue
|
|
|
|
x = rect_x + 40 + line.style.indent
|
|
current_x = x
|
|
|
|
|
|
for char in line.text:
|
|
if emoji.is_emoji(char):
|
|
|
|
emoji_font = font_manager.fonts['emoji_30']
|
|
bbox = draw.textbbox((current_x, current_y), char, font=emoji_font)
|
|
|
|
draw.text((current_x, current_y), char, font=emoji_font, embedded_color=True)
|
|
current_x += bbox[2] - bbox[0]
|
|
else:
|
|
|
|
font = font_manager.get_font(line.style)
|
|
bbox = draw.textbbox((current_x, current_y), char, font=font)
|
|
draw.text((current_x, current_y), char, font=font, fill=text_color)
|
|
current_x += bbox[2] - bbox[0]
|
|
|
|
if i < len(processed_lines) - 1:
|
|
current_y += line.height + line.style.line_spacing
|
|
else:
|
|
current_y += line.height
|
|
|
|
|
|
background = background.convert('RGB')
|
|
background.save(output_path, "PNG", optimize=False, compress_level=0)
|
|
|
|
except Exception as e:
|
|
print(f"Error generating image: {e}")
|
|
raise |