File size: 33,330 Bytes
768b041 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 |
# utf-8
# image_generator.py
"""
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' # regular, bold, emoji
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)
# emoji字体
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 [
# Mac 高级白
{
"start_color": (246, 246, 248), # 珍珠白
"end_color": (250, 250, 252) # 云雾白
},
{
"start_color": (245, 245, 247), # 奶白色
"end_color": (248, 248, 250) # 象牙白
},
# macOS Monterey 风格
{
"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 在 0 到 1 之间表示渐变程度
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:
# 深色毛玻璃效果: 深色半透明背景(50%透明度) + 白色文字
return ((50, 50, 50, 128), "#FFFFFF", True) # alpha值调整为128实现50%透明度
else:
# 浅色毛玻璃效果: 白色半透明背景(50%透明度) + 黑色文字
return ((255, 255, 255, 128), "#000000", False) # alpha值调整为128实现50%透明度
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 # 使用带透明度的背景色
)
# 使用alpha通道混合方式粘贴到背景上
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:
# 如果图片不是RGBA模式,转换为RGBA
if title_img.mode != 'RGBA':
title_img = title_img.convert('RGBA')
# 设置图片宽度等于文字区域宽度
target_width = rect_width - 40 # 左右各留20像素边距
# 计算等比例缩放后的高度
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 # 左边距20像素
y = rect_y + 20 # 顶部边距20像素
# 粘贴图片(使用图片自身的alpha通道)
background.paste(rounded_img, (x, y), rounded_img)
return y + target_height + 20 # 返回图片底部位置加上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) # 设置 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)]
# 处理emoji标题格式
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, # 使用H1的字体大小
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)]
# 处理emoji标题格式
# 匹配模式:emoji + 空格 + (可选的**) + 内容 + (可选的**)
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, # 使用H1的字体大小
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) # 设置 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):
# 使用emoji字体
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) # emoji作为单独的词
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): # 3MB in bytes
"""
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).
"""
# Open the image
with Image.open(image_path) as img:
# Convert to RGB if it's not already
if img.mode != 'RGB':
img = img.convert('RGB')
# Define the quality to start with
quality = 95 # Start with a high quality
# Save the image with different qualities until the file size is acceptable
while True:
# Save the image with the current quality
img.save(output_path, "PNG", optimize=True, compress_level=0)
# Check the file size
if os.path.getsize(output_path) <= max_size:
break # The file size is acceptable, break the loop
# If the file is still too large, decrease the quality
quality -= 5
if quality < 10: # To prevent an infinite loop, set a minimum quality
break
# If the quality is too low, you might want to handle it here
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") # 或其他彩色emoji字体
}
# 验证字体文件
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
# 创建RGBA背景
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,) # 添加alpha通道
# 创建卡片背景
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字体渲染
emoji_font = font_manager.fonts['emoji_30']
bbox = draw.textbbox((current_x, current_y), char, font=emoji_font)
# 使用RGBA模式绘制emoji
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
# 直接保存为PNG,保持RGBA模式
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 |