from typing import Dict, Union
import json
from PIL import Image, ImageDraw, ImageFont
import argparse
def merge_bounding_boxes(bounding_boxes):
"""合并所有有实际文字内容的boundingbox"""
min_x = min(box['x'] for box in bounding_boxes)
min_y = min(box['y'] for box in bounding_boxes)
max_x = max(box['x'] + box['width'] for box in bounding_boxes)
max_y = max(box['y'] + box['height'] for box in bounding_boxes)
ascent = max(box['ascent'] for box in bounding_boxes)
descent = min(box['descent'] for box in bounding_boxes)
return {
'x': min_x,
'y': min_y,
'width': max_x - min_x,
'height': max_y - min_y,
'ascent': ascent,
'descent': descent
}
def measure_text_bounds(text, font_family, font_size, font_weight="normal"):
"""测量文本的边界框尺寸"""
# 创建临时图像用于测量文本
img = Image.new('RGB', (1, 1), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# 获取字体
font = get_font(font_family, font_size, font_weight)
# 获取文本尺寸
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
width = right - left
height = bottom - top
result = {
'width': width,
'height': height,
'min_x': left,
'min_y': top,
'max_x': right,
'max_y': bottom
}
return result
def get_font(font_family, font_size, font_weight="normal"):
"""获取字体对象,处理各种字体格式和降级情况"""
# 处理特殊字体名称
if font_family and font_family.lower() == 'comics':
font_family = 'Comic Sans MS, cursive'
# 从字体大小中提取数字部分
if isinstance(font_size, str):
font_size = int(font_size.replace('px', ''))
try:
# 首先尝试加载系统字体
if font_weight == "bold":
font = ImageFont.truetype(font_family, size=font_size, weight="bold")
else:
font = ImageFont.truetype(font_family, size=font_size)
except (OSError, IOError):
try:
# 如果直接加载失败,尝试一些常见的系统字体
system_fonts = {
'Arial': '/usr/share/fonts/truetype/msttcorefonts/Arial.ttf',
'Times': '/usr/share/fonts/truetype/msttcorefonts/Times_New_Roman.ttf',
'Courier': '/usr/share/fonts/truetype/msttcorefonts/Courier_New.ttf',
'Verdana': '/usr/share/fonts/truetype/msttcorefonts/Verdana.ttf',
'Comic': '/usr/share/fonts/truetype/msttcorefonts/Comic_Sans_MS.ttf',
'Default': '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
}
# 尝试匹配字体名称
for name, path in system_fonts.items():
if name.lower() in font_family.lower():
font = ImageFont.truetype(path, size=font_size)
return font
# 如果没有匹配,使用默认字体
font = ImageFont.truetype(system_fonts['Default'], size=font_size)
except (OSError, IOError):
# 如果所有尝试都失败,使用PIL的默认字体并尝试调整大小
default_font = ImageFont.load_default()
# PIL的默认字体不支持调整大小,所以我们必须警告用户
print(f"警告: 无法加载指定字体和大小 '{font_family}', {font_size}px。使用默认字体。")
font = default_font
return font
def split_text_into_lines(text, max_width, font_family="Arial", font_size=16, font_weight="normal"):
"""将文本按照给定的宽度限制拆分成多行"""
# 创建临时图像用于测量文本宽度
img = Image.new('RGB', (1, 1), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# 获取字体
font = get_font(font_family, font_size, font_weight)
lines = []
# 检测是否包含中文字符
has_chinese = any('\u4e00' <= char <= '\u9fff' for char in text)
if has_chinese:
# 中文文本按字符切分
current_line = ("", 0)
for char in text:
test_line = current_line[0] + char
# 获取文本宽度
text_width = draw.textlength(test_line, font=font)
if text_width <= max_width:
current_line = (test_line, text_width)
else:
lines.append(current_line)
current_line = (char, draw.textlength(char, font=font))
# 添加最后一行
if current_line:
lines.append(current_line)
else:
# 英文文本按单词切分
words = text.split()
current_line = ("", 0)
for word in words:
# 测试添加这个单词后是否超出宽度
test_line = current_line[0] + (" " if current_line[0] else "") + word
text_width = draw.textlength(test_line, font=font)
if text_width <= max_width:
current_line = (test_line, text_width)
else:
if current_line:
lines.append(current_line)
current_line = (word, draw.textlength(word, font=font))
# 检查单个单词是否超过最大宽度
if draw.textlength(word, font=font) > max_width:
# 如果单个单词就超过宽度,则需要逐字分割
word_line = ""
for char in word:
test_word_line = word_line + char
if draw.textlength(test_word_line, font=font) <= max_width:
word_line = test_word_line
else:
word_line = word_line + "-"
lines.append((word_line, draw.textlength(word_line, font=font)))
word_line = char
if word_line:
current_line = (word_line, draw.textlength(word_line, font=font))
if current_line not in lines:
lines.append(current_line)
current_line = ("", 0)
# 添加最后一行
if current_line:
lines.append(current_line)
# 确保至少有一行
if not lines:
lines = [(text, draw.textlength(text, font=font))]
return lines
class TitleGenerator:
def __init__(self, json_data: Dict, max_width = 0, text_align = "left", show_embellishment = True, show_sub_title = True, font_family = None):
self.json_data = json_data
self.max_width = max_width
self.text_align = text_align # 保留接口,但内部只实现左对齐
self.show_embellishment = show_embellishment
self.show_sub_title = show_sub_title
self.font_family = font_family
def generate(self):
self.main_title_svg, self.main_title_bounding_box = self.generate_main_title()
if self.show_sub_title:
self.description_svg, self.description_bounding_box = self.generate_description()
else:
self.description_svg = ""
self.description_bounding_box = {
'width': 0, 'height': 0,
'min_x': 0, 'min_y': 0,
'max_x': 0, 'max_y': 0
}
primary_color = self.json_data['colors']['other']['primary']
if self.show_embellishment:
self.embellishment_svg, self.embellishment_bounding_box = self.generate_embellishment(primary_color)
else:
# 创建空的装饰块,不会显示在最终结果中
self.embellishment_svg = ""
self.embellishment_bounding_box = {
'width': 0, 'height': 0,
'min_x': 0, 'min_y': 0,
'max_x': 0, 'max_y': 0
}
return self.composite()
def composite(self):
if self.show_sub_title:
description_shift_y = self.main_title_bounding_box['max_y'] + 15 - self.main_title_bounding_box['min_y']
description_shift_x = self.description_bounding_box['min_x'] - self.main_title_bounding_box['min_x']
if self.text_align == "right":
title_width = self.main_title_bounding_box['max_x'] - self.main_title_bounding_box['min_x']
description_width = self.description_bounding_box['max_x'] - self.description_bounding_box['min_x']
description_shift_x = title_width - description_width
description_transform = f'translate({0}, {description_shift_y})'
self.description_svg = self.description_svg.replace('transform="', f'transform="{description_transform} ')
self.description_bounding_box['min_x'] += 0
self.description_bounding_box['min_y'] += description_shift_y
self.description_bounding_box['max_x'] += 0
self.description_bounding_box['max_y'] += description_shift_y
# 如果显示装饰块,调整其位置和大小
if self.show_embellishment:
new_height = self.description_bounding_box['max_y'] - self.main_title_bounding_box['min_y']
old_height = self.embellishment_bounding_box['height']
old_width = self.embellishment_bounding_box['width']
scale = new_height / old_height
new_width = old_width * scale
# 装饰块在左边
embellishment_shift_x = self.main_title_bounding_box['min_x'] - self.embellishment_bounding_box['min_x'] - new_width - 15
embellishment_shift_y = self.main_title_bounding_box['min_y'] - self.embellishment_bounding_box['min_y']
# 通过添加transform属性,调整embellishment_svg的位置
embellishment_transform = f'translate({embellishment_shift_x}, {embellishment_shift_y})'
self.embellishment_svg = self.embellishment_svg.replace('transform="', f'transform="{embellishment_transform} ')
self.embellishment_bounding_box['min_x'] += embellishment_shift_x
self.embellishment_bounding_box['min_y'] += embellishment_shift_y
self.embellishment_bounding_box['max_x'] += embellishment_shift_x
self.embellishment_bounding_box['max_y'] += embellishment_shift_y
old_width_text = self.embellishment_svg.split('width="')[1].split('"')[0]
old_height_text = self.embellishment_svg.split('height="')[1].split('"')[0]
new_width_text = str(int(float(old_width_text) * scale))
new_height_text = str(int(float(old_height_text) * scale))
# 通过修改width和height,调整embellishment_svg的大小
self.embellishment_svg = self.embellishment_svg.replace(old_width_text, new_width_text)
self.embellishment_svg = self.embellishment_svg.replace(old_height_text, new_height_text)
# Update embellishment bounding box with new dimensions after scaling
self.embellishment_bounding_box['width'] = float(new_width_text)
self.embellishment_bounding_box['height'] = float(new_height_text)
self.embellishment_bounding_box['max_x'] = self.embellishment_bounding_box['min_x'] + float(new_width_text)
self.embellishment_bounding_box['max_y'] = self.embellishment_bounding_box['min_y'] + float(new_height_text)
# 计算整体边界框
min_x = min(
self.main_title_bounding_box['min_x'],
self.description_bounding_box['min_x'] if self.show_sub_title else float('inf'),
self.embellishment_bounding_box['min_x'] if self.show_embellishment else float('inf')
)
min_y = min(
self.main_title_bounding_box['min_y'],
self.description_bounding_box['min_y'] if self.show_sub_title else float('inf'),
self.embellishment_bounding_box['min_y'] if self.show_embellishment else float('inf')
)
max_x = max(
self.main_title_bounding_box['max_x'],
self.description_bounding_box['max_x'] if self.show_sub_title else float('-inf'),
self.embellishment_bounding_box['max_x'] if self.show_embellishment else float('-inf')
)
max_y = max(
self.main_title_bounding_box['max_y'],
self.description_bounding_box['max_y'] if self.show_sub_title else float('-inf'),
self.embellishment_bounding_box['max_y'] if self.show_embellishment else float('-inf')
)
group_left = f''
group_right = ''
svg_left = f''
svg_content = svg_left + group_left + self.embellishment_svg + self.main_title_svg + self.description_svg + group_right + svg_right
final_bounding_box = {
'width': max_x - min_x,
'height': max_y - min_y,
'min_x': 0,
'min_y': 0,
'max_x': max_x - min_x,
'max_y': max_y - min_y
}
return svg_content, final_bounding_box
def generate_text_element(self, text: str, typography: Dict, max_width: int = 0, text_align: str = "left"):
"""生成文本元素,包括SVG和边界框"""
text_svg = self.generate_one_line_text(typography, text, max_width, text_align)
# 使用PIL直接测量文本尺寸
font_family = typography.get('font_family', 'Arial')
if self.font_family: # 如果全局字体被设置,优先使用全局字体
font_family = self.font_family
# 如果字体是comics,自动转换为Comic Sans MS, cursive
if font_family and font_family.lower() == 'comics':
font_family = 'Comic Sans MS, cursive'
font_size = typography.get('font_size', '16px')
font_weight = typography.get('font_weight', 'normal')
bounding_box = measure_text_bounds(text, font_family, font_size, font_weight)
# 检查是否超出最大宽度,并且生成多行文本
if max_width > 0 and bounding_box['width'] > max_width:
text_svg, bounding_box = self.generate_multi_line_text(typography, text, max_width, text_align)
return text_svg, bounding_box
def generate_main_title(self):
"""生成主标题"""
main_title_text = self.json_data['titles']['main_title']
typography = self.json_data['typography']['title']
return self.generate_text_element(main_title_text, typography, self.max_width, self.text_align)
def generate_description(self):
"""生成描述文本"""
description_text = self.json_data['titles']['sub_title']
typography = self.json_data['typography']['description']
return self.generate_text_element(description_text, typography, self.max_width, self.text_align)
def generate_embellishment(self, color = '#000000'):
rect = f''
bounding_box = {
'width': 15,
'height': 150,
'min_x': 0,
'min_y': 0,
'max_x': 15,
'max_y': 150
}
return rect, bounding_box
def generate_one_line_text(self, typography: Dict, text: str, max_width: int = 0, text_align: str = "left"):
font_family = typography.get('font_family', 'Arial')
if self.font_family: # 如果全局字体被设置,优先使用全局字体
font_family = self.font_family
# 如果字体是comics,自动转换为Comic Sans MS, cursive
if font_family and font_family.lower() == 'comics':
font_family = 'Comic Sans MS, cursive'
font_size = typography.get('font_size', '16px')
font_weight = typography.get('font_weight', 'normal')
text_anchor = "start"
x = 0
if text_align == "center":
text_anchor = "middle"
x = max_width / 2
elif text_align == "right":
text_anchor = "end"
x = max_width
text_left = f''
text_right = ''
return text_left + text + text_right
def generate_multi_line_text(self, typography: Dict, text: str, max_width: int, text_align: str = "left"):
"""生成多行文本,确保每行不超过最大宽度"""
font_family = typography.get('font_family', 'Arial')
if self.font_family: # 如果全局字体被设置,优先使用全局字体
font_family = self.font_family
# 如果字体是comics,自动转换为Comic Sans MS, cursive
if font_family and font_family.lower() == 'comics':
font_family = 'Comic Sans MS, cursive'
font_size = typography.get('font_size', '16px')
font_weight = typography.get('font_weight', 'normal')
# 使用拆分文本函数获取多行
lines = split_text_into_lines(text, max_width, font_family, font_size, font_weight)
# 生成多行SVG
if isinstance(font_size, str):
font_size_px = int(font_size.replace('px', ''))
else:
font_size_px = font_size
line_height = font_size_px * 1.2 # 行高约为字体大小的1.2倍
g_left = ''
text_content = ""
text_anchor = "start"
if text_align == "center":
text_anchor = "middle"
elif text_align == "right":
text_anchor = "end"
for i, (line, line_width) in enumerate(lines):
y = i * line_height
x = 0
if text_align == "right":
x = max_width
elif text_align == "center":
x = max_width / 2
text_style = f'style="font-family: {font_family}; font-size: {font_size}; font-weight: {font_weight};"'
text_content += f'{line}'
g_right = ''
text_svg = g_left + text_content + g_right
# 计算整体边界框
if len(lines) == 1:
bounding_box = measure_text_bounds(lines[0][0], font_family, font_size, font_weight)
else:
# 对于多行文本,计算整体边界框
max_line_width = 0
for (line, line_width) in lines:
max_line_width = max(max_line_width, line_width)
total_height = line_height * (len(lines) - 1) + font_size_px
bounding_box = {
'width': max_line_width,
'height': total_height,
'min_x': 0,
'min_y': 0,
'max_x': max_line_width,
'max_y': total_height
}
return text_svg, bounding_box
def process(
input: str = None,
output: str = None,
input_data: Dict = None,
max_width: int = 500,
text_align: str = "left",
show_embellishment: bool = True,
show_sub_title: bool = True,
font_family: str = None
) -> Union[bool, str]:
"""
Process function for generating styled title SVG from input data.
Args:
input (str, optional): Path to the input JSON file.
output (str, optional): Path to the output SVG file.
input_data (Dict, optional): Input data dictionary (alternative to file input).
max_width (int, optional): Maximum width constraint for the title. Defaults to 500.
text_align (str, optional): Text alignment. Options: "left", "center", "right". Defaults to "left".
show_embellishment (bool, optional): Whether to show the decoration element. Defaults to True.
show_sub_title (bool, optional): Whether to show the subtitle. Defaults to True.
font_family (str, optional): Font family to use for all text. Defaults to None (use from typography).
Returns:
Union[bool, str]:
- If output is provided, returns True/False indicating success/failure.
- Otherwise, returns the generated SVG content as a string.
"""
try:
# Load the data object
if input_data is None:
if input is None:
return False
with open(input, 'r', encoding='utf-8') as f:
data = json.load(f)
else:
data = input_data
# Generate the title SVG
title_generator = TitleGenerator(data, max_width=max_width,
text_align=text_align,
show_embellishment=show_embellishment,
show_sub_title=show_sub_title,
font_family=font_family)
svg_content, bounding_box = title_generator.generate()
if output:
with open(output, 'w', encoding='utf-8') as f:
f.write(svg_content)
return True
return svg_content
except Exception as e:
print(f"Error in title styling: {str(e)}")
return False
def main():
parser = argparse.ArgumentParser(description='Generate styled title SVG for a chart')
parser.add_argument('--input', '-i', type=str, required=True, help='Input JSON file path')
parser.add_argument('--output', '-o', type=str, help='Output SVG file path')
parser.add_argument('--max-width', '-w', type=int, default=500, help='Maximum width constraint for the title')
parser.add_argument('--text-align', '-a', type=str, default='left', choices=['left', 'center', 'right'],
help='Text alignment: left, center, or right')
parser.add_argument('--no-embellishment', action='store_true', help='Hide the decoration element')
parser.add_argument('--no-subtitle', action='store_true', help='Hide the subtitle')
parser.add_argument('--font', type=str, help='Font family to use for all text (e.g. Arial, Comic, Times)')
args = parser.parse_args()
# 处理font参数中的comics
if args.font and args.font.lower() == 'comics':
args.font = 'Comic Sans MS, cursive'
success = process(
input=args.input,
output=args.output,
max_width=args.max_width,
text_align=args.text_align,
show_embellishment=not args.no_embellishment,
show_sub_title=not args.no_subtitle,
font_family=args.font
)
if success:
print("Title styling completed successfully.")
else:
print("Title styling failed.")
if __name__ == '__main__':
main()