├── .env_example
├── .idea
├── .gitignore
├── Text2Card.iml
├── inspectionProfiles
│ ├── Project_Default.xml
│ └── profiles_settings.xml
├── misc.xml
├── modules.xml
└── vcs.xml
├── LICENSE
├── README.md
├── README_EN.md
├── TwitterColorEmoji.ttf
├── app.py
├── assets
├── example_card.png
└── example_card1.png
├── config.py
├── generate_image.py
├── msyh.ttc
├── msyhbd.ttc
└── requirements.txt
/.env_example:
--------------------------------------------------------------------------------
1 | # 服务器配置
2 | ENV=development
3 | DEVELOPMENT_HOST=http://127.0.0.1:3000
4 | PRODUCTION_HOST=https://your-production-domain.com
5 | PORT=3000
6 |
7 | # 安全配置
8 | SECRET_KEY=your-secret-key-here
9 | API_KEYS=["sk-123456"] # JSON array format
10 | TOKEN_EXPIRY=3600 # Seconds
11 |
12 | # 存储配置
13 | UPLOAD_FOLDER=picture
14 | MAX_CONTENT_LENGTH=10485760 # 10MB in bytes
15 |
16 | # Logging Configuration
17 | LOG_LEVEL=INFO
18 | LOG_FORMAT=%(asctime)s - %(levelname)s - %(message)s
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/Text2Card.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Cookpro
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 📝 Text2Card 项目说明
2 |
3 | [English](./README_EN.md) | [中文](./README.md)
4 |
5 |
6 | ## ✅ 前言
7 | Text2Card 是一个小而美的工具,能够将文本内容转换为精美的图片卡片。相比使用无头浏览器截图的方式,Text2Card 更加轻量,不依赖外部服务,直接通过函数调用生成图片,性能高效且易于集成。现已支持 OpenAI API 格式调用,可轻松集成到各类 AI 应用中。
8 |
9 | ## 🚀 功能特性
10 | - **OpenAI API兼容**:支持标准 OpenAI API 格式调用,易于集成。
11 | - **安全认证机制**:基于 token 的图片访问控制,支持 API 密钥认证。
12 | - **卡片多主题配色**:支持多种渐变背景配色,卡片风格多样。
13 | - **Markdown解析渲染**:支持基本的 Markdown 语法解析,如标题、列表等。
14 | - **日间夜间模式自动切换**:根据时间自动切换日间和夜间模式。
15 | - **图片标题**:支持在卡片顶部添加图片标题。
16 | - **支持emoji渲染展示**:能够正确渲染和展示emoji表情。
17 | - **超清图片保存**:生成的图片清晰度高,适合分享和展示。
18 | - **自动清理机制**:定期清理过期图片文件。
19 |
20 | **PS:添加WeChat:Imladrsaon 转发微信公号文章可自动summary(Url2Text还能整理好,后续发)**
21 |
22 | ## 🖼️ 效果图片展示
23 | 以下是使用 Text2Card 生成的图片示例:
24 | 
25 |
26 | (注:上图展示了 Text2Card 生成的卡片效果。)
27 |
28 | ## 🛠️ 环境安装
29 |
30 | ### 1. 克隆项目
31 | ```bash
32 | git clone https://github.com/LargeCupPanda/text2card.git
33 | cd text2card
34 | ```
35 |
36 | ### 2. 环境配置
37 | 将`env_example` 复制为 `.env` 文件,并设置必要的环境变量:
38 | ```plaintext
39 | # 服务器配置
40 | ENV=development
41 | DEVELOPMENT_HOST=http://127.0.0.1:3000
42 | PRODUCTION_HOST=https://your-production-domain.com
43 | PORT=3000
44 |
45 | # 安全配置
46 | SECRET_KEY=your-secret-key-here
47 | API_KEYS=["your-test-api-key"]
48 | TOKEN_EXPIRY=3600
49 |
50 | # 存储配置
51 | UPLOAD_FOLDER=picture
52 | MAX_CONTENT_LENGTH=10485760
53 | ```
54 |
55 | ### 3. 安装依赖
56 | ```bash
57 | # 虚拟环境(可选)
58 | python3 -m venv venv
59 | source venv/bin/activate # Windows: venv\Scripts\activate
60 |
61 | # 安装依赖
62 | pip install -r requirements.txt
63 | ```
64 |
65 | ### 4. 字体文件准备
66 | 确保以下字体文件存在于项目根目录:
67 | - `msyh.ttc`(微软雅黑常规字体)
68 | - `msyhbd.ttc`(微软雅黑粗体)
69 | - `TwitterColorEmoji.ttf`(彩色emoji字体)
70 |
71 | ## 📡 API 使用说明
72 |
73 | ### OpenAI 格式调用
74 | ```python
75 | import requests
76 |
77 | def generate_card(text, api_key):
78 | url = "http://127.0.0.1:3000/v1/chat/completions"
79 | headers = {
80 | "Authorization": f"Bearer {api_key}",
81 | "Content-Type": "application/json"
82 | }
83 | data = {
84 | "model": "Text2Card",
85 | "messages": [
86 | {
87 | "role": "user",
88 | "content": text
89 | }
90 | ]
91 | }
92 | response = requests.post(url, headers=headers, json=data)
93 | return response.json()
94 |
95 | # 使用示例
96 | result = generate_card("要转换的文本内容", "your-api-key")
97 | print(result)
98 | ```
99 |
100 | ### 响应格式
101 | ```json
102 | {
103 | "id": "text2card-1234567890",
104 | "object": "chat.completion",
105 | "created": 1234567890,
106 | "model": "Text2Card",
107 | "choices": [{
108 | "index": 0,
109 | "message": {
110 | "role": "assistant",
111 | "content": "http://127.0.0.1:3000/v1/images/20250102123456_abcdef.png"
112 | },
113 | "finish_reason": "stop"
114 | }]
115 | }
116 | ```
117 |
118 | ## 📂 项目结构
119 | ```
120 | text2card/
121 | ├── assets/
122 | │ └── example_card.png # 效果图
123 | ├── image_generator.py # 图片生成主逻辑
124 | ├── app.py # API 服务器实现
125 | ├── config.py # 配置管理
126 | ├── .env # 环境变量配置
127 | ├── requirements.txt # 依赖文件
128 | ├── msyh.ttc # 微软雅黑常规字体
129 | ├── msyhbd.ttc # 微软雅黑粗体
130 | ├── TwitterColorEmoji.ttf # 彩色emoji字体
131 | └── README.md # 项目说明文档
132 | ```
133 |
134 | ## 🔐 安全说明
135 | - API 密钥认证:所有请求需要通过 API 密钥认证
136 | - URL Token:图片访问使用临时 token,增强安全性
137 | - 文件清理:自动清理过期文件,避免存储占用
138 | - 环境隔离:支持开发和生产环境配置隔离
139 |
140 | ## 🤝 贡献与反馈
141 | 如果你有任何建议或发现问题,欢迎提交 Issue 或 Pull Request。我们非常欢迎社区的贡献!
142 |
143 | ## 📄 许可证
144 | 本项目采用 MIT 许可证,详情请参阅 [LICENSE](LICENSE) 文件。
145 |
146 | ---
147 | 希望这个工具能帮助你轻松生成精美的图片卡片!如有问题或建议,欢迎随时反馈。🎉
148 |
--------------------------------------------------------------------------------
/README_EN.md:
--------------------------------------------------------------------------------
1 | # 📝 Text2Card Project Documentation
2 |
3 | [English](./README_EN.md) | [中文](./README.md)
4 |
5 | ## ✅ Introduction
6 | Text2Card is an elegant tool designed to convert text content into beautiful image cards. Unlike screenshot methods using headless browsers, Text2Card is lightweight, independent of external services, and generates images directly through function calls, making it highly efficient and easy to integrate. Now with OpenAI API compatibility, it can be seamlessly integrated into various AI applications.
7 |
8 | ## 🚀 Features
9 | - **OpenAI API Compatible**: Supports standard OpenAI API format calls for easy integration.
10 | - **Secure Authentication**: Token-based image access control with API key authentication.
11 | - **Multiple Theme Colors**: Supports various gradient background colors for diverse card styles.
12 | - **Markdown Parsing**: Supports basic Markdown syntax parsing, including headers and lists.
13 | - **Auto Day/Night Mode**: Automatically switches between day and night modes based on time.
14 | - **Image Titles**: Supports adding image titles at the top of cards.
15 | - **Emoji Rendering**: Correctly renders and displays emoji characters.
16 | - **High-Quality Output**: Generates high-resolution images suitable for sharing.
17 | - **Auto Cleanup**: Periodically cleans expired image files.
18 |
19 | ## 🖼️ Example Showcase
20 | Here's an example of an image generated using Text2Card:
21 | 
22 | (Note: The above image demonstrates the card effect generated by Text2Card.)
23 |
24 | ## 🛠️ Installation
25 |
26 | ### 1. Clone the Repository
27 | ```bash
28 | git clone https://github.com/LargeCupPanda/text2card.git
29 | cd text2card
30 | ```
31 |
32 | ### 2. Environment Configuration
33 | Create a `.env` file and set the necessary environment variables:
34 | ```plaintext
35 | # Server Configuration
36 | ENV=development
37 | DEVELOPMENT_HOST=http://127.0.0.1:3000
38 | PRODUCTION_HOST=https://your-production-domain.com
39 | PORT=3000
40 |
41 | # Security Configuration
42 | SECRET_KEY=your-secret-key-here
43 | API_KEYS=["your-test-api-key"]
44 | TOKEN_EXPIRY=3600
45 |
46 | # Storage Configuration
47 | UPLOAD_FOLDER=picture
48 | MAX_CONTENT_LENGTH=10485760
49 | ```
50 |
51 | ### 3. Install Dependencies
52 | ```bash
53 | python3 -m venv venv
54 | source venv/bin/activate # Windows: venv\Scripts\activate
55 | pip install -r requirements.txt
56 | ```
57 |
58 | ### 4. Font File Preparation
59 | Ensure the following font files are present in the project root directory:
60 | - `msyh.ttc` (Microsoft YaHei Regular)
61 | - `msyhbd.ttc` (Microsoft YaHei Bold)
62 | - `TwitterColorEmoji.ttf` (Color Emoji Font)
63 |
64 | ## 📡 API Usage Guide
65 |
66 | ### OpenAI Format Call
67 | ```python
68 | import requests
69 |
70 | def generate_card(text, api_key):
71 | url = "http://127.0.0.1:3000/v1/chat/completions"
72 | headers = {
73 | "Authorization": f"Bearer {api_key}",
74 | "Content-Type": "application/json"
75 | }
76 | data = {
77 | "model": "Text2Card",
78 | "messages": [
79 | {
80 | "role": "user",
81 | "content": text
82 | }
83 | ]
84 | }
85 | response = requests.post(url, headers=headers, json=data)
86 | return response.json()
87 |
88 | # Usage Example
89 | result = generate_card("Text to convert", "your-api-key")
90 | print(result)
91 | ```
92 |
93 | ### Response Format
94 | ```json
95 | {
96 | "id": "text2card-1234567890",
97 | "object": "chat.completion",
98 | "created": 1234567890,
99 | "model": "Text2Card",
100 | "choices": [{
101 | "index": 0,
102 | "message": {
103 | "role": "assistant",
104 | "content": "http://127.0.0.1:3000/v1/images/20250102123456_abcdef.png"
105 | },
106 | "finish_reason": "stop"
107 | }]
108 | }
109 | ```
110 |
111 | ## 📂 Project Structure
112 | ```
113 | text2card/
114 | ├── assets/
115 | │ └── example_card.png # Example image
116 | ├── image_generator.py # Image generation logic
117 | ├── app.py # API server implementation
118 | ├── config.py # Configuration management
119 | ├── .env # Environment variables
120 | ├── requirements.txt # Dependencies
121 | ├── msyh.ttc # Microsoft YaHei Regular font
122 | ├── msyhbd.ttc # Microsoft YaHei Bold font
123 | ├── TwitterColorEmoji.ttf # Color emoji font
124 | └── README.md # Project documentation
125 | ```
126 |
127 | ## 🔐 Security Notes
128 | - API Key Authentication: All requests require API key authentication
129 | - URL Token: Image access uses temporary tokens for enhanced security
130 | - File Cleanup: Automatically cleans expired files to prevent storage buildup
131 | - Environment Isolation: Supports separate development and production configurations
132 |
133 | ## 🤝 Contributing
134 | If you have any suggestions or discover issues, please feel free to submit an Issue or Pull Request. Community contributions are highly welcome!
135 |
136 | ## 📄 License
137 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
138 |
139 | ---
140 | We hope this tool helps you easily generate beautiful image cards! If you have any questions or suggestions, feel free to reach out. 🎉
--------------------------------------------------------------------------------
/TwitterColorEmoji.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LargeCupPanda/Text2Card/254ef4d41a54434961b79f2ec53ff1ffc02b967a/TwitterColorEmoji.ttf
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | """
2 | Text2Card API Server
3 | 提供符合 OpenAI API 格式的文字转图片卡片服务
4 |
5 | 主要功能:
6 | 1. 文字转卡片图片生成
7 | 2. 安全的URL token认证
8 | 3. 图片存储和服务
9 | 4. 环境配置管理
10 |
11 | API 端点:
12 | - POST /v1/chat/completions: 生成图片卡片
13 | - GET /v1/images/: 获取生成的图片
14 |
15 | 使用方法:
16 | curl -X POST http://localhost:3000/v1/chat/completions \
17 | -H "Authorization: Bearer your-api-key" \
18 | -H "Content-Type: application/json" \
19 | -d '{
20 | "model": "Text2Card",
21 | "messages": [{"role": "user", "content": "要转换的文本内容"}]
22 | }'
23 | """
24 |
25 | from flask import Flask, request, jsonify, send_file, make_response
26 | from flask_cors import CORS
27 | from functools import wraps
28 | import os
29 | from datetime import datetime
30 | import uuid
31 | import time
32 | import logging
33 | from typing import Optional, Dict, Any, Tuple
34 |
35 | # 导入配置
36 | from config import config
37 |
38 | # 初始化日志
39 | config.setup_logging()
40 | logger = logging.getLogger(__name__)
41 |
42 | # 初始化Flask应用
43 | app = Flask(__name__)
44 | CORS(app) # 启用跨域支持
45 | app.config['MAX_CONTENT_LENGTH'] = config.MAX_CONTENT_LENGTH
46 |
47 | def generate_unique_filename() -> str:
48 | """
49 | 生成唯一的文件名
50 | 使用时间戳和UUID组合确保唯一性
51 |
52 | Returns:
53 | str: 格式为 'YYYYMMDDHHMMSS_UUID.png' 的文件名
54 | """
55 | timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
56 | unique_id = uuid.uuid4().hex
57 | return f"{timestamp}_{unique_id}.png"
58 |
59 | def create_error_response(message: str, error_type: str, status_code: int) -> Tuple[Dict[str, Any], int]:
60 | """
61 | 创建标准化的错误响应
62 |
63 | Args:
64 | message: 错误消息
65 | error_type: 错误类型
66 | status_code: HTTP状态码
67 |
68 | Returns:
69 | tuple: (错误响应字典, 状态码)
70 | """
71 | return {
72 | 'error': {
73 | 'message': message,
74 | 'type': error_type
75 | }
76 | }, status_code
77 |
78 | def require_api_key(f):
79 | """
80 | API密钥验证装饰器
81 | 验证请求头中的Authorization字段
82 |
83 | Args:
84 | f: 被装饰的函数
85 | Returns:
86 | 函数: 包装后的函数
87 | """
88 | @wraps(f)
89 | def decorated(*args, **kwargs):
90 | auth_header = request.headers.get('Authorization')
91 |
92 | if not auth_header or not auth_header.startswith('Bearer '):
93 | logger.warning("Missing or invalid API key format")
94 | return make_response(
95 | jsonify(create_error_response(
96 | '缺少或无效的 API key',
97 | 'authentication_error',
98 | 401
99 | ))
100 | )
101 |
102 | api_key = auth_header.split(' ')[1]
103 |
104 | if api_key not in config.API_KEYS:
105 | logger.warning(f"Invalid API key attempted: {api_key[:6]}...")
106 | return make_response(
107 | jsonify(create_error_response(
108 | '无效的 API key',
109 | 'authentication_error',
110 | 401
111 | ))
112 | )
113 |
114 | return f(*args, **kwargs)
115 | return decorated
116 |
117 | def validate_chat_request(data: Dict[str, Any]) -> Optional[Tuple[Dict[str, Any], int]]:
118 | """
119 | 验证聊天请求数据
120 |
121 | Args:
122 | data: 请求数据字典
123 |
124 | Returns:
125 | Optional[tuple]: 如果验证失败返回错误响应,否则返回None
126 | """
127 | if not data:
128 | return create_error_response('缺少请求数据', 'invalid_request_error', 400)
129 |
130 | if 'model' not in data or data['model'] != 'Text2Card':
131 | return create_error_response(
132 | '无效的模型指定。请使用 model: Text2Card',
133 | 'invalid_request_error',
134 | 400
135 | )
136 |
137 | if 'messages' not in data or not data['messages']:
138 | return create_error_response('需要提供消息数组', 'invalid_request_error', 400)
139 |
140 | return None
141 |
142 | @app.route('/v1/chat/completions', methods=['POST'])
143 | @require_api_key
144 | def chat_completions():
145 | """
146 | OpenAI兼容的聊天补全API端点
147 | 接收文本内容并生成对应的图片卡片
148 |
149 | Expected Request:
150 | {
151 | "model": "Text2Card",
152 | "messages": [
153 | {"role": "user", "content": "要转换的文本内容"}
154 | ]
155 | }
156 |
157 | Returns:
158 | JSON响应: 包含生成的图片URL或错误信息
159 | """
160 | try:
161 | data = request.json
162 |
163 | # 验证请求数据
164 | validation_error = validate_chat_request(data)
165 | if validation_error:
166 | return jsonify(validation_error[0]), validation_error[1]
167 |
168 | # 提取最后一条用户消息
169 | last_message = None
170 | for msg in reversed(data['messages']):
171 | if msg.get('role') == 'user':
172 | last_message = msg.get('content')
173 | break
174 |
175 | if not last_message:
176 | return jsonify(create_error_response(
177 | '未找到有效的用户消息',
178 | 'invalid_request_error',
179 | 400
180 | ))
181 |
182 | # 生成图片
183 | output_filename = generate_unique_filename()
184 | output_path = os.path.join(config.UPLOAD_FOLDER, output_filename)
185 |
186 | # 调用图片生成函数
187 | from generate_image import generate_image
188 | generate_image(last_message, output_path)
189 |
190 | # 验证图片生成是否成功
191 | if not os.path.exists(output_path):
192 | logger.error(f"Failed to generate image: {output_path}")
193 | return jsonify(create_error_response(
194 | '图片生成失败',
195 | 'server_error',
196 | 500
197 | ))
198 |
199 | # 生成带token的图片URL
200 | token, expiry = config.generate_image_token(output_filename)
201 | image_url = f"{config.base_url}/v1/images/{output_filename}?token={token}&expiry={expiry}"
202 |
203 | # 构造OpenAI格式的响应
204 | response = {
205 | 'id': f'text2card-{int(time.time())}',
206 | 'object': 'chat.completion',
207 | 'created': int(time.time()),
208 | 'model': 'Text2Card',
209 | 'choices': [{
210 | 'index': 0,
211 | 'message': {
212 | 'role': 'assistant',
213 | 'content': image_url
214 | },
215 | 'finish_reason': 'stop'
216 | }],
217 | 'usage': {
218 | 'prompt_tokens': len(last_message),
219 | 'completion_tokens': 0,
220 | 'total_tokens': len(last_message)
221 | }
222 | }
223 |
224 | logger.info(f"Successfully generated image: {output_filename}")
225 | return jsonify(response)
226 |
227 | except Exception as e:
228 | logger.error(f"Error in chat_completions: {str(e)}", exc_info=True)
229 | return jsonify(create_error_response(
230 | f'服务器错误: {str(e)}',
231 | 'server_error',
232 | 500
233 | ))
234 |
235 | @app.route('/v1/images/')
236 | def serve_image(filename):
237 | """
238 | 提供图片服务的端点,使用URL token验证
239 |
240 | Args:
241 | filename (str): 请求的图片文件名
242 |
243 | URL Parameters:
244 | token (str): 访问令牌
245 | expiry (str): 过期时间戳
246 |
247 | Returns:
248 | file: 图片文件或错误响应
249 | """
250 | try:
251 | # 获取URL参数
252 | token = request.args.get('token')
253 | expiry = request.args.get('expiry')
254 |
255 | # 验证参数
256 | if not token or not expiry:
257 | logger.warning(f"Missing token or expiry for image: {filename}")
258 | return jsonify(create_error_response(
259 | '缺少访问令牌',
260 | 'authentication_error',
261 | 401
262 | ))
263 |
264 | # 验证token
265 | if not config.verify_image_token(filename, token, expiry):
266 | logger.warning(f"Invalid or expired token for image: {filename}")
267 | return jsonify(create_error_response(
268 | '无效或过期的访问令牌',
269 | 'authentication_error',
270 | 401
271 | ))
272 |
273 | # 提供图片
274 | file_path = os.path.join(config.UPLOAD_FOLDER, filename)
275 | if not os.path.exists(file_path):
276 | logger.error(f"Image file not found: {file_path}")
277 | return jsonify(create_error_response(
278 | '图片文件不存在',
279 | 'not_found_error',
280 | 404
281 | ))
282 |
283 | return send_file(file_path, mimetype='image/png')
284 |
285 | except Exception as e:
286 | logger.error(f"Error serving image {filename}: {str(e)}")
287 | return jsonify(create_error_response(
288 | '图片未找到或访问出错',
289 | 'not_found_error',
290 | 404
291 | ))
292 |
293 | @app.errorhandler(413)
294 | def request_entity_too_large(error):
295 | """处理请求体过大的错误"""
296 | return jsonify(create_error_response(
297 | '请求数据过大',
298 | 'payload_too_large',
299 | 413
300 | ))
301 |
302 | @app.errorhandler(404)
303 | def not_found(error):
304 | """处理404错误"""
305 | return jsonify(create_error_response(
306 | '请求的资源不存在',
307 | 'not_found_error',
308 | 404
309 | ))
310 |
311 | @app.errorhandler(500)
312 | def internal_server_error(error):
313 | """处理500错误"""
314 | return jsonify(create_error_response(
315 | '服务器内部错误',
316 | 'server_error',
317 | 500
318 | ))
319 |
320 | def cleanup_old_images():
321 | """
322 | 清理过期的图片文件
323 | """
324 | try:
325 | current_time = time.time()
326 | for filename in os.listdir(config.UPLOAD_FOLDER):
327 | file_path = os.path.join(config.UPLOAD_FOLDER, filename)
328 | # 如果文件超过24小时未被访问
329 | if os.path.exists(file_path) and \
330 | current_time - os.path.getmtime(file_path) > 86400:
331 | os.remove(file_path)
332 | logger.info(f"Cleaned up old image: {filename}")
333 | except Exception as e:
334 | logger.error(f"Error cleaning up old images: {str(e)}")
335 |
336 | if __name__ == "__main__":
337 | # 启动前清理旧文件
338 | cleanup_old_images()
339 |
340 | # 输出启动信息
341 | logger.info(f"Starting Text2Card API server in {config.ENV} mode")
342 | logger.info(f"Server URL: {config.base_url}")
343 | logger.info(f"Upload folder: {config.UPLOAD_FOLDER}")
344 | logger.info(f"Maximum content length: {config.MAX_CONTENT_LENGTH} bytes")
345 |
346 | # 启动服务器
347 | app.run(
348 | debug=config.ENV == "development",
349 | port=config.PORT,
350 | host='0.0.0.0' if config.ENV == 'production' else '127.0.0.1'
351 | )
352 |
--------------------------------------------------------------------------------
/assets/example_card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LargeCupPanda/Text2Card/254ef4d41a54434961b79f2ec53ff1ffc02b967a/assets/example_card.png
--------------------------------------------------------------------------------
/assets/example_card1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LargeCupPanda/Text2Card/254ef4d41a54434961b79f2ec53ff1ffc02b967a/assets/example_card1.png
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | """
2 | Configuration Management Module
3 | 处理应用程序的配置管理,包括环境变量加载、验证和访问
4 | """
5 |
6 | import os
7 | import json
8 | from typing import List, Optional
9 | from datetime import datetime
10 | import hashlib
11 | from dotenv import load_dotenv
12 | import logging
13 | from pathlib import Path
14 |
15 | # 加载环境变量
16 | load_dotenv()
17 |
18 | class ConfigurationError(Exception):
19 | """配置错误异常类"""
20 | pass
21 |
22 | class Config:
23 | """
24 | 应用配置类
25 | 处理配置加载、验证和访问的核心类
26 | """
27 |
28 | def __init__(self):
29 | """初始化配置,加载和验证所有必需的配置值"""
30 | # 环境配置
31 | self.ENV: str = self._get_env('ENV', 'development')
32 | self.DEVELOPMENT_HOST: str = self._get_env('DEVELOPMENT_HOST', 'http://127.0.0.1:3000')
33 | self.PRODUCTION_HOST: str = self._get_env('PRODUCTION_HOST')
34 | self.PORT: int = int(self._get_env('PORT', '3000'))
35 |
36 | # 安全配置
37 | self.SECRET_KEY: str = self._get_env('SECRET_KEY')
38 | self.API_KEYS: List[str] = self._parse_json_env('API_KEYS', '[]')
39 | self.TOKEN_EXPIRY: int = int(self._get_env('TOKEN_EXPIRY', '3600'))
40 |
41 | # 存储配置
42 | self.UPLOAD_FOLDER: str = self._get_env('UPLOAD_FOLDER', 'picture')
43 | self.MAX_CONTENT_LENGTH: int = int(self._get_env('MAX_CONTENT_LENGTH', '10485760'))
44 |
45 | # 日志配置
46 | self.LOG_LEVEL: str = self._get_env('LOG_LEVEL', 'INFO')
47 | self.LOG_FORMAT: str = self._get_env('LOG_FORMAT',
48 | '%(asctime)s - %(levelname)s - %(message)s')
49 |
50 | # 验证配置
51 | self._validate_configuration()
52 |
53 | # 确保上传目录存在
54 | self._ensure_upload_folder()
55 |
56 | def _get_env(self, key: str, default: Optional[str] = None) -> str:
57 | """
58 | 获取环境变量值
59 |
60 | Args:
61 | key: 环境变量名
62 | default: 默认值(可选)
63 |
64 | Returns:
65 | str: 环境变量值
66 |
67 | Raises:
68 | ConfigurationError: 如果必需的环境变量未设置且没有默认值
69 | """
70 | value = os.getenv(key, default)
71 | if value is None:
72 | raise ConfigurationError(f"必需的环境变量 {key} 未设置")
73 | return value
74 |
75 | def _parse_json_env(self, key: str, default: str = '[]') -> List[str]:
76 | """
77 | 解析JSON格式的环境变量
78 |
79 | Args:
80 | key: 环境变量名
81 | default: 默认JSON字符串
82 |
83 | Returns:
84 | List[str]: 解析后的列表
85 | """
86 | try:
87 | return json.loads(self._get_env(key, default))
88 | except json.JSONDecodeError as e:
89 | raise ConfigurationError(f"环境变量 {key} JSON解析错误: {str(e)}")
90 |
91 | def _validate_configuration(self):
92 | """
93 | 验证配置的有效性
94 |
95 | Raises:
96 | ConfigurationError: 当配置无效时
97 | """
98 | if not self.SECRET_KEY:
99 | raise ConfigurationError("SECRET_KEY 不能为空")
100 |
101 | if self.ENV not in ['development', 'production']:
102 | raise ConfigurationError("ENV 必须是 'development' 或 'production'")
103 |
104 | if self.ENV == 'production' and not self.PRODUCTION_HOST:
105 | raise ConfigurationError("生产环境需要设置 PRODUCTION_HOST")
106 |
107 | def _ensure_upload_folder(self):
108 | """确保上传目录存在"""
109 | Path(self.UPLOAD_FOLDER).mkdir(parents=True, exist_ok=True)
110 |
111 | @property
112 | def base_url(self) -> str:
113 | """
114 | 获取当前环境的基础URL
115 |
116 | Returns:
117 | str: 完整的基础URL
118 | """
119 | return self.DEVELOPMENT_HOST if self.ENV == 'development' else self.PRODUCTION_HOST
120 |
121 | def generate_image_token(self, filename: str) -> tuple:
122 | """
123 | 为图片URL生成安全的访问token
124 |
125 | Args:
126 | filename: 图片文件名
127 |
128 | Returns:
129 | tuple: (token, expiry) token和过期时间戳
130 | """
131 | timestamp = int(datetime.now().timestamp())
132 | expiry = timestamp + self.TOKEN_EXPIRY
133 | message = f"{filename}{expiry}{self.SECRET_KEY}"
134 | token = hashlib.sha256(message.encode()).hexdigest()[:32]
135 | return token, expiry
136 |
137 | def verify_image_token(self, filename: str, token: str, expiry: str) -> bool:
138 | """
139 | 验证图片访问token是否有效
140 |
141 | Args:
142 | filename: 图片文件名
143 | token: 访问token
144 | expiry: 过期时间戳
145 |
146 | Returns:
147 | bool: token是否有效
148 | """
149 | try:
150 | if int(datetime.now().timestamp()) > int(expiry):
151 | return False
152 | expected_token = hashlib.sha256(
153 | f"{filename}{expiry}{self.SECRET_KEY}".encode()
154 | ).hexdigest()[:32]
155 | return token == expected_token
156 | except ValueError:
157 | return False
158 |
159 | def setup_logging(self):
160 | """配置日志系统"""
161 | logging.basicConfig(
162 | level=getattr(logging, self.LOG_LEVEL.upper()),
163 | format=self.LOG_FORMAT
164 | )
165 |
166 | # 创建全局配置实例
167 | config = Config()
--------------------------------------------------------------------------------
/generate_image.py:
--------------------------------------------------------------------------------
1 | # utf-8
2 | # image_generator.py
3 | """
4 | Advanced image card generator with markdown and emoji support
5 | """
6 | import math
7 | import random
8 | import os
9 | from PIL import Image, ImageDraw, ImageFont, ImageOps
10 | import emoji
11 | import re
12 | from dataclasses import dataclass
13 | from typing import List, Tuple, Optional, Dict
14 | from datetime import datetime
15 |
16 |
17 | @dataclass
18 | class TextStyle:
19 | """文本样式定义"""
20 | font_name: str = 'regular' # regular, bold, emoji
21 | font_size: int = 30 # 字体大小
22 | indent: int = 0 # 缩进像素
23 | line_spacing: int = 15 # 行间距
24 | is_title: bool = False # 是否为标题
25 | is_category: bool = False # 是否为分类标题
26 | keep_with_next: bool = False # 是否与下一行保持在一起
27 |
28 |
29 | @dataclass
30 | class TextSegment:
31 | """文本片段定义"""
32 | text: str # 文本内容
33 | style: TextStyle # 样式
34 | original_text: str = '' # 原始文本(用于调试)
35 |
36 |
37 | @dataclass
38 | class ProcessedLine:
39 | """处理后的行信息"""
40 | text: str # 实际文本内容
41 | style: TextStyle # 样式信息
42 | height: int = 0 # 行高
43 | line_count: int = 1 # 实际占用行数
44 |
45 |
46 | class FontManager:
47 | """字体管理器"""
48 |
49 | def __init__(self, font_paths: Dict[str, str]):
50 | self.fonts = {}
51 | self.font_paths = font_paths
52 | self._initialize_fonts()
53 |
54 | def _initialize_fonts(self):
55 | """初始化基础字体"""
56 | sizes = [30, 35, 40] # 基础字号
57 | for size in sizes:
58 | self.fonts[f'regular_{size}'] = ImageFont.truetype(self.font_paths['regular'], size)
59 | self.fonts[f'bold_{size}'] = ImageFont.truetype(self.font_paths['bold'], size)
60 | # emoji字体
61 | self.fonts['emoji_30'] = ImageFont.truetype(self.font_paths['emoji'], 30)
62 |
63 | def get_font(self, style: TextStyle) -> ImageFont.FreeTypeFont:
64 | """获取对应样式的字体"""
65 | if style.font_name == 'emoji':
66 | return self.fonts['emoji_30']
67 |
68 | base_name = 'bold' if style.font_name == 'bold' or style.is_title or style.is_category else 'regular'
69 | font_key = f'{base_name}_{style.font_size}'
70 |
71 | if font_key not in self.fonts:
72 | # 动态创建新字号的字体
73 | self.fonts[font_key] = ImageFont.truetype(
74 | self.font_paths['bold' if base_name == 'bold' else 'regular'],
75 | style.font_size
76 | )
77 |
78 | return self.fonts[font_key]
79 |
80 |
81 | def get_gradient_styles() -> List[Dict[str, tuple]]:
82 | """
83 | 获取精心设计的背景渐变样式
84 | """
85 | return [
86 |
87 | # Mac 高级白
88 | {
89 | "start_color": (246, 246, 248), # 珍珠白
90 | "end_color": (250, 250, 252) # 云雾白
91 | },
92 | {
93 | "start_color": (245, 245, 247), # 奶白色
94 | "end_color": (248, 248, 250) # 象牙白
95 | },
96 | # macOS Monterey 风格
97 | {
98 | "start_color": (191, 203, 255), # 淡蓝紫
99 | "end_color": (255, 203, 237) # 浅粉红
100 | },
101 | {
102 | "start_color": (168, 225, 255), # 天空蓝
103 | "end_color": (203, 255, 242) # 清新薄荷
104 | },
105 |
106 | # 优雅渐变系列
107 | {
108 | "start_color": (255, 209, 209), # 珊瑚粉
109 | "end_color": (243, 209, 255) # 淡紫色
110 | },
111 | {
112 | "start_color": (255, 230, 209), # 奶橘色
113 | "end_color": (255, 209, 247) # 粉紫色
114 | },
115 |
116 | # 清新通透
117 | {
118 | "start_color": (213, 255, 219), # 嫩绿色
119 | "end_color": (209, 247, 255) # 浅蓝色
120 | },
121 | {
122 | "start_color": (255, 236, 209), # 杏橘色
123 | "end_color": (255, 209, 216) # 浅玫瑰
124 | },
125 |
126 | # 高级灰调
127 | {
128 | "start_color": (237, 240, 245), # 珍珠灰
129 | "end_color": (245, 237, 245) # 薰衣草灰
130 | },
131 | {
132 | "start_color": (240, 245, 255), # 云雾蓝
133 | "end_color": (245, 240, 245) # 淡紫灰
134 | },
135 |
136 | # 梦幻糖果色
137 | {
138 | "start_color": (255, 223, 242), # 棉花糖粉
139 | "end_color": (242, 223, 255) # 淡紫丁香
140 | },
141 | {
142 | "start_color": (223, 255, 247), # 薄荷绿
143 | "end_color": (223, 242, 255) # 天空蓝
144 | },
145 |
146 | # 高饱和度系列
147 | {
148 | "start_color": (255, 192, 203), # 粉红色
149 | "end_color": (192, 203, 255) # 淡紫蓝
150 | },
151 | {
152 | "start_color": (192, 255, 238), # 碧绿色
153 | "end_color": (238, 192, 255) # 淡紫色
154 | },
155 |
156 | # 静谧系列
157 | {
158 | "start_color": (230, 240, 255), # 宁静蓝
159 | "end_color": (255, 240, 245) # 柔粉色
160 | },
161 | {
162 | "start_color": (245, 240, 255), # 淡紫色
163 | "end_color": (240, 255, 240) # 清新绿
164 | },
165 |
166 | # 温柔渐变
167 | {
168 | "start_color": (255, 235, 235), # 温柔粉
169 | "end_color": (235, 235, 255) # 淡雅紫
170 | },
171 | {
172 | "start_color": (235, 255, 235), # 嫩芽绿
173 | "end_color": (255, 235, 245) # 浅粉红
174 | }
175 | ]
176 |
177 |
178 | def create_gradient_background(width: int, height: int) -> Image.Image:
179 | """创建渐变背景 - 从左上到右下的对角线渐变"""
180 | gradient_styles = get_gradient_styles()
181 | style = random.choice(gradient_styles)
182 | start_color = style["start_color"]
183 | end_color = style["end_color"]
184 |
185 | # 创建基础图像
186 | base = Image.new('RGB', (width, height))
187 | draw = ImageDraw.Draw(base)
188 |
189 | # 计算渐变
190 | for y in range(height):
191 | for x in range(width):
192 | # 计算当前位置到左上角的相对距离 (对角线渐变)
193 | # 使用 position 在 0 到 1 之间表示渐变程度
194 | position = (x + y) / (width + height)
195 |
196 | # 为每个颜色通道计算渐变值
197 | r = int(start_color[0] * (1 - position) + end_color[0] * position)
198 | g = int(start_color[1] * (1 - position) + end_color[1] * position)
199 | b = int(start_color[2] * (1 - position) + end_color[2] * position)
200 |
201 | # 绘制像素
202 | draw.point((x, y), fill=(r, g, b))
203 |
204 | return base
205 |
206 |
207 | def get_theme_colors() -> Tuple[tuple, str, bool]:
208 | """获取主题颜色配置"""
209 | current_hour = datetime.now().hour
210 | current_minute = datetime.now().minute
211 |
212 | if (current_hour == 8 and current_minute >= 30) or (9 <= current_hour < 19):
213 | use_dark = random.random() < 0.1
214 | else:
215 | use_dark = True
216 |
217 | if use_dark:
218 | # 深色毛玻璃效果: 深色半透明背景(50%透明度) + 白色文字
219 | return ((50, 50, 50, 128), "#FFFFFF", True) # alpha值调整为128实现50%透明度
220 | else:
221 | # 浅色毛玻璃效果: 白色半透明背景(50%透明度) + 黑色文字
222 | return ((255, 255, 255, 128), "#000000", False) # alpha值调整为128实现50%透明度
223 |
224 |
225 | def create_rounded_rectangle(image: Image.Image, x: int, y: int, w: int, h: int, radius: int, bg_color: tuple):
226 | """创建圆角毛玻璃矩形"""
227 | # 创建透明背景的矩形
228 | rectangle = Image.new('RGBA', (int(w), int(h)), (0, 0, 0, 0))
229 | draw = ImageDraw.Draw(rectangle)
230 |
231 | # 绘制带透明度的圆角矩形
232 | draw.rounded_rectangle(
233 | [(0, 0), (int(w), int(h))],
234 | radius,
235 | fill=bg_color # 使用带透明度的背景色
236 | )
237 |
238 | # 使用alpha通道混合方式粘贴到背景上
239 | image.paste(rectangle, (int(x), int(y)), rectangle)
240 |
241 |
242 | def round_corner_image(image: Image.Image, radius: int) -> Image.Image:
243 | """将图片转为圆角"""
244 | # 创建一个带有圆角的蒙版
245 | circle = Image.new('L', (radius * 2, radius * 2), 0)
246 | draw = ImageDraw.Draw(circle)
247 | draw.ellipse((0, 0, radius * 2, radius * 2), fill=255)
248 |
249 | # 创建一个完整的蒙版
250 | mask = Image.new('L', image.size, 255)
251 |
252 | # 添加四个圆角
253 | mask.paste(circle.crop((0, 0, radius, radius)), (0, 0)) # 左上
254 | mask.paste(circle.crop((radius, 0, radius * 2, radius)), (image.width - radius, 0)) # 右上
255 | mask.paste(circle.crop((0, radius, radius, radius * 2)), (0, image.height - radius)) # 左下
256 | mask.paste(circle.crop((radius, radius, radius * 2, radius * 2)),
257 | (image.width - radius, image.height - radius)) # 右下
258 |
259 | # 创建一个空白的透明图像
260 | output = Image.new('RGBA', image.size, (0, 0, 0, 0))
261 |
262 | # 将原图和蒙版合并
263 | output.paste(image, (0, 0))
264 | output.putalpha(mask)
265 |
266 | return output
267 |
268 |
269 | def add_title_image(background: Image.Image, title_image_path: str, rect_x: int, rect_y: int, rect_width: int) -> int:
270 | """添加标题图片"""
271 | try:
272 | with Image.open(title_image_path) as title_img:
273 | # 如果图片不是RGBA模式,转换为RGBA
274 | if title_img.mode != 'RGBA':
275 | title_img = title_img.convert('RGBA')
276 |
277 | # 设置图片宽度等于文字区域宽度
278 | target_width = rect_width - 40 # 左右各留20像素边距
279 |
280 | # 计算等比例缩放后的高度
281 | aspect_ratio = title_img.height / title_img.width
282 | target_height = int(target_width * aspect_ratio)
283 |
284 | # 调整图片大小
285 | resized_img = title_img.resize((int(target_width), target_height), Image.Resampling.LANCZOS)
286 |
287 | # 添加圆角
288 | rounded_img = round_corner_image(resized_img, radius=20) # 可以调整圆角半径
289 |
290 | # 计算居中位置(水平方向)
291 | x = rect_x + 20 # 左边距20像素
292 | y = rect_y + 20 # 顶部边距20像素
293 |
294 | # 粘贴图片(使用图片自身的alpha通道)
295 | background.paste(rounded_img, (x, y), rounded_img)
296 |
297 | return y + target_height + 20 # 返回图片底部位置加上20像素间距
298 | except Exception as e:
299 | print(f"Error loading title image: {e}")
300 | return rect_y + 30
301 |
302 |
303 | class MarkdownParser:
304 | """Markdown解析器"""
305 |
306 | def __init__(self):
307 | self.reset()
308 |
309 | def reset(self):
310 | self.segments = []
311 | self.current_section = None # 当前处理的段落类型
312 |
313 | def parse(self, text: str) -> List[TextSegment]:
314 | """解析整个文本"""
315 | self.reset()
316 | segments = []
317 | lines = text.splitlines()
318 |
319 | for i, line in enumerate(lines):
320 | line = line.strip()
321 | if not line:
322 | # 只有当下一行有内容时才添加空行
323 | next_has_content = False
324 | for next_line in lines[i + 1:]:
325 | if next_line.strip():
326 | next_has_content = True
327 | break
328 | if next_has_content:
329 | style = TextStyle(
330 | line_spacing=20 if segments and segments[-1].style.is_title else 15
331 | )
332 | segments.append(TextSegment(text='', style=style))
333 | continue
334 |
335 | # 处理常规行
336 | line_segments = self.parse_line(line)
337 | segments.extend(line_segments)
338 |
339 | # 只在确定有下一行内容时添加空行
340 | if i < len(lines) - 1:
341 | has_next_content = False
342 | for next_line in lines[i + 1:]:
343 | if next_line.strip():
344 | has_next_content = True
345 | break
346 | if has_next_content:
347 | style = line_segments[-1].style
348 | segments.append(TextSegment(text='', style=TextStyle(line_spacing=style.line_spacing)))
349 |
350 | # 最后添加签名,不添加任何额外空行
351 | if segments:
352 | signature = TextSegment(
353 | text=" —By 嫣然",
354 | style=TextStyle(font_name='regular', indent=0, line_spacing=0) # 设置 line_spacing=0
355 | )
356 | segments.append(signature)
357 |
358 | return segments
359 |
360 | def is_category_title(self, text: str) -> bool:
361 | """判断是否为分类标题"""
362 | return text.strip() in ['国内要闻', '国际动态']
363 |
364 | def process_title_marks(self, text: str) -> str:
365 | """处理标题标记"""
366 | # 移除 ** 标记
367 | text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
368 | # 统一中文冒号
369 | text = text.replace(':', ':')
370 | return text
371 |
372 | def split_number_and_content(self, text: str) -> Tuple[str, str]:
373 | """分离序号和内容"""
374 | match = re.match(r'(\d+)\.\s*(.+)', text)
375 | if match:
376 | return match.group(1), match.group(2)
377 | return '', text
378 |
379 | def split_title_and_content(self, text: str) -> Tuple[str, str]:
380 | """分离标题和内容"""
381 | parts = text.split(':', 1)
382 | if len(parts) == 2:
383 | return parts[0] + ':', parts[1].strip()
384 | return text, ''
385 |
386 | def parse_line(self, text: str) -> List[TextSegment]:
387 | """解析单行文本"""
388 | if not text.strip():
389 | return [TextSegment(text='', style=TextStyle())]
390 |
391 | # 处理一级标题
392 | if text.startswith('# '):
393 | style = TextStyle(
394 | font_name='bold',
395 | font_size=40,
396 | is_title=True,
397 | indent=0
398 | )
399 | return [TextSegment(text=text[2:].strip(), style=style)]
400 |
401 | # 处理二级标题
402 | if text.startswith('## '):
403 | style = TextStyle(
404 | font_name='bold',
405 | font_size=35,
406 | is_title=True,
407 | line_spacing=25,
408 | indent=0
409 | )
410 | self.current_section = text[3:].strip()
411 | return [TextSegment(text=self.current_section, style=style)]
412 |
413 | # 处理分类标题
414 | if self.is_category_title(text):
415 | style = TextStyle(
416 | font_name='bold',
417 | font_size=35,
418 | is_category=True,
419 | line_spacing=25,
420 | indent=0
421 | )
422 | return [TextSegment(text=text.strip(), style=style)]
423 |
424 | # 处理emoji标题格式
425 | if text.strip() and emoji.is_emoji(text[0]):
426 | # 移除文本中的加粗标记 **
427 | content = text.strip()
428 | if '**' in content:
429 | content = content.replace('**', '')
430 |
431 | style = TextStyle(
432 | font_name='bold',
433 | font_size=40, # 使用H1的字体大小
434 | is_title=True,
435 | line_spacing=25,
436 | indent=0
437 | )
438 | return [TextSegment(text=content, style=style)]
439 |
440 | # 处理带序号的新闻条目
441 | number, content = self.split_number_and_content(text)
442 | if number:
443 | content = self.process_title_marks(content)
444 | title, body = self.split_title_and_content(content)
445 | segments = []
446 |
447 | title_style = TextStyle(
448 | font_name='bold',
449 | indent=0,
450 | is_title=True,
451 | line_spacing=15 if body else 20
452 | )
453 | segments.append(TextSegment(
454 | text=f"{number}. {title}",
455 | style=title_style
456 | ))
457 |
458 | if body:
459 | content_style = TextStyle(
460 | font_name='regular',
461 | indent=40,
462 | line_spacing=20
463 | )
464 | segments.append(TextSegment(
465 | text=body,
466 | style=content_style
467 | ))
468 | return segments
469 |
470 | # 处理破折号开头的内容
471 | if text.strip().startswith('-'):
472 | style = TextStyle(
473 | font_name='regular',
474 | indent=40,
475 | line_spacing=15
476 | )
477 | return [TextSegment(text=text.strip(), style=style)]
478 |
479 | # 处理普通文本
480 | style = TextStyle(
481 | font_name='regular',
482 | indent=40 if self.current_section else 0,
483 | line_spacing=15
484 | )
485 |
486 | return [TextSegment(text=text.strip(), style=style)]
487 |
488 |
489 | def parse_line(self, text: str) -> List[TextSegment]:
490 | """解析单行文本"""
491 | if not text.strip():
492 | return [TextSegment(text='', style=TextStyle())]
493 |
494 | # 处理一级标题
495 | if text.startswith('# '):
496 | style = TextStyle(
497 | font_name='bold',
498 | font_size=40,
499 | is_title=True,
500 | indent=0
501 | )
502 | return [TextSegment(text=text[2:].strip(), style=style)]
503 |
504 | # 处理二级标题
505 | if text.startswith('## '):
506 | style = TextStyle(
507 | font_name='bold',
508 | font_size=35,
509 | is_title=True,
510 | line_spacing=25,
511 | indent=0
512 | )
513 | self.current_section = text[3:].strip()
514 | return [TextSegment(text=self.current_section, style=style)]
515 |
516 | # 处理分类标题
517 | if self.is_category_title(text):
518 | style = TextStyle(
519 | font_name='bold',
520 | font_size=35,
521 | is_category=True,
522 | line_spacing=25,
523 | indent=0
524 | )
525 | return [TextSegment(text=text.strip(), style=style)]
526 |
527 | # 处理emoji标题格式
528 | # 匹配模式:emoji + 空格 + (可选的**) + 内容 + (可选的**)
529 | if text.strip() and emoji.is_emoji(text[0]):
530 | # 移除文本中的加粗标记 **
531 | content = text.strip()
532 | if '**' in content:
533 | content = content.replace('**', '')
534 |
535 | style = TextStyle(
536 | font_name='bold',
537 | font_size=40, # 使用H1的字体大小
538 | is_title=True,
539 | line_spacing=25,
540 | indent=0
541 | )
542 | return [TextSegment(text=content, style=style)]
543 |
544 | # 处理带序号的新闻条目
545 | number, content = self.split_number_and_content(text)
546 | if number:
547 | content = self.process_title_marks(content)
548 | title, body = self.split_title_and_content(content)
549 | segments = []
550 |
551 | title_style = TextStyle(
552 | font_name='bold',
553 | indent=0,
554 | is_title=True,
555 | line_spacing=15 if body else 20
556 | )
557 | segments.append(TextSegment(
558 | text=f"{number}. {title}",
559 | style=title_style
560 | ))
561 |
562 | if body:
563 | content_style = TextStyle(
564 | font_name='regular',
565 | indent=40,
566 | line_spacing=20
567 | )
568 | segments.append(TextSegment(
569 | text=body,
570 | style=content_style
571 | ))
572 | return segments
573 |
574 | # 处理破折号开头的内容
575 | if text.strip().startswith('-'):
576 | style = TextStyle(
577 | font_name='regular',
578 | indent=40,
579 | line_spacing=15
580 | )
581 | return [TextSegment(text=text.strip(), style=style)]
582 |
583 | # 处理普通文本
584 | style = TextStyle(
585 | font_name='regular',
586 | indent=40 if self.current_section else 0,
587 | line_spacing=15
588 | )
589 |
590 | return [TextSegment(text=text.strip(), style=style)]
591 |
592 | def parse(self, text: str) -> List[TextSegment]:
593 | """解析整个文本"""
594 | self.reset()
595 | segments = []
596 | lines = text.splitlines()
597 |
598 | for i, line in enumerate(lines):
599 | line = line.strip()
600 | if not line:
601 | # 只有当下一行有内容时才添加空行
602 | next_has_content = False
603 | for next_line in lines[i + 1:]:
604 | if next_line.strip():
605 | next_has_content = True
606 | break
607 | if next_has_content:
608 | style = TextStyle(
609 | line_spacing=20 if segments and segments[-1].style.is_title else 15
610 | )
611 | segments.append(TextSegment(text='', style=style))
612 | continue
613 |
614 | # 处理常规行
615 | line_segments = self.parse_line(line)
616 | segments.extend(line_segments)
617 |
618 | # 只在确定有下一行内容时添加空行
619 | if i < len(lines) - 1:
620 | has_next_content = False
621 | for next_line in lines[i + 1:]:
622 | if next_line.strip():
623 | has_next_content = True
624 | break
625 | if has_next_content:
626 | style = line_segments[-1].style
627 | segments.append(TextSegment(text='', style=TextStyle(line_spacing=style.line_spacing)))
628 |
629 | # 最后添加签名,不添加任何额外空行
630 | if segments:
631 | signature = TextSegment(
632 | text=" —By 嫣然",
633 | style=TextStyle(font_name='regular', indent=0, line_spacing=0) # 设置 line_spacing=0
634 | )
635 | segments.append(signature)
636 |
637 | return segments
638 |
639 |
640 | class TextRenderer:
641 | """文本渲染器"""
642 |
643 | def __init__(self, font_manager: FontManager, max_width: int):
644 | self.font_manager = font_manager
645 | self.max_width = max_width
646 | self.temp_image = Image.new('RGBA', (2000, 100))
647 | self.temp_draw = ImageDraw.Draw(self.temp_image)
648 |
649 | def measure_text(self, text: str, font: ImageFont.FreeTypeFont,
650 | emoji_font: Optional[ImageFont.FreeTypeFont] = None) -> Tuple[int, int]:
651 | """测量文本尺寸,考虑emoji"""
652 | total_width = 0
653 | max_height = 0
654 |
655 | for char in text:
656 | if emoji.is_emoji(char) and emoji_font:
657 | bbox = self.temp_draw.textbbox((0, 0), char, font=emoji_font)
658 | width = bbox[2] - bbox[0]
659 | height = bbox[3] - bbox[1]
660 | else:
661 | bbox = self.temp_draw.textbbox((0, 0), char, font=font)
662 | width = bbox[2] - bbox[0]
663 | height = bbox[3] - bbox[1]
664 |
665 | total_width += width
666 | max_height = max(max_height, height)
667 |
668 | return total_width, max_height
669 |
670 | def draw_text_with_emoji(self, draw: ImageDraw.ImageDraw, pos: Tuple[int, int], text: str,
671 | font: ImageFont.FreeTypeFont, emoji_font: ImageFont.FreeTypeFont,
672 | fill: str = "white") -> int:
673 | """绘制包含emoji的文本,返回绘制宽度"""
674 | x, y = pos
675 | total_width = 0
676 |
677 | for char in text:
678 | if emoji.is_emoji(char):
679 | # 使用emoji字体
680 | bbox = draw.textbbox((x, y), char, font=emoji_font)
681 | draw.text((x, y), char, font=emoji_font, fill=fill)
682 | char_width = bbox[2] - bbox[0]
683 | else:
684 | # 使用常规字体
685 | bbox = draw.textbbox((x, y), char, font=font)
686 | draw.text((x, y), char, font=font, fill=fill)
687 | char_width = bbox[2] - bbox[0]
688 |
689 | x += char_width
690 | total_width += char_width
691 |
692 | return total_width
693 |
694 | def calculate_height(self, processed_lines: List[ProcessedLine]) -> int:
695 | """计算总高度,确保不在最后添加额外间距"""
696 | total_height = 0
697 | prev_line = None
698 |
699 | for i, line in enumerate(processed_lines):
700 | if not line.text.strip():
701 | # 只有当不是最后一行,且后面还有内容时才添加间距
702 | if i < len(processed_lines) - 1 and any(l.text.strip() for l in processed_lines[i + 1:]):
703 | if prev_line:
704 | total_height += prev_line.style.line_spacing
705 | continue
706 |
707 | # 计算当前行高度
708 | line_height = line.height * line.line_count
709 |
710 | # 签名的高度
711 | if i == len(processed_lines) - 1:
712 | line_height += 40
713 |
714 | # 添加行间距,但不在最后一行后添加
715 | if prev_line and i < len(processed_lines) - 1:
716 | if prev_line.style.is_category:
717 | total_height += 30
718 | elif prev_line.style.is_title and not line.style.is_title:
719 | total_height += 20
720 | else:
721 | total_height += line.style.line_spacing
722 |
723 | total_height += line_height
724 | prev_line = line
725 |
726 | return total_height
727 |
728 | def split_text_to_lines(self, segment: TextSegment, available_width: int) -> List[ProcessedLine]:
729 | """将文本分割成合适宽度的行,支持emoji"""
730 | if not segment.text.strip():
731 | return [ProcessedLine(text='', style=segment.style, height=0, line_count=1)]
732 |
733 | font = self.font_manager.get_font(segment.style)
734 | emoji_font = self.font_manager.fonts['emoji_30']
735 | words = []
736 | current_word = ''
737 | processed_lines = []
738 |
739 | # 分词处理
740 | for char in segment.text:
741 | if emoji.is_emoji(char):
742 | if current_word:
743 | words.append(current_word)
744 | current_word = ''
745 | words.append(char) # emoji作为单独的词
746 | elif char in [' ', ',', '。', ':', '、', '!', '?', ';']:
747 | if current_word:
748 | words.append(current_word)
749 | words.append(char)
750 | current_word = ''
751 | else:
752 | if ord(char) > 0x4e00: # 中文字符
753 | if current_word:
754 | words.append(current_word)
755 | current_word = ''
756 | words.append(char)
757 | else:
758 | current_word += char
759 |
760 | if current_word:
761 | words.append(current_word)
762 |
763 | current_line = ''
764 | line_height = 0
765 |
766 | for word in words:
767 | test_line = current_line + word
768 | width, height = self.measure_text(test_line, font, emoji_font)
769 | line_height = max(line_height, height)
770 |
771 | if width <= available_width:
772 | current_line = test_line
773 | else:
774 | if current_line:
775 | processed_lines.append(ProcessedLine(
776 | text=current_line,
777 | style=segment.style,
778 | height=line_height,
779 | line_count=1
780 | ))
781 | current_line = word
782 |
783 | if current_line:
784 | processed_lines.append(ProcessedLine(
785 | text=current_line,
786 | style=segment.style,
787 | height=line_height,
788 | line_count=1
789 | ))
790 |
791 | return processed_lines
792 |
793 |
794 | def compress_image(image_path: str, output_path: str, max_size: int = 3145728): # 3MB in bytes
795 | """
796 | Compress an image to ensure it's under a certain file size.
797 |
798 | :param image_path: The path to the image to be compressed.
799 | :param output_path: The path where the compressed image will be saved.
800 | :param max_size: The maximum file size in bytes (default is 3MB).
801 | """
802 | # Open the image
803 | with Image.open(image_path) as img:
804 | # Convert to RGB if it's not already
805 | if img.mode != 'RGB':
806 | img = img.convert('RGB')
807 |
808 | # Define the quality to start with
809 | quality = 95 # Start with a high quality
810 |
811 | # Save the image with different qualities until the file size is acceptable
812 | while True:
813 | # Save the image with the current quality
814 | img.save(output_path, "PNG", optimize=True, compress_level=0)
815 |
816 | # Check the file size
817 | if os.path.getsize(output_path) <= max_size:
818 | break # The file size is acceptable, break the loop
819 |
820 | # If the file is still too large, decrease the quality
821 | quality -= 5
822 | if quality < 10: # To prevent an infinite loop, set a minimum quality
823 | break
824 |
825 | # If the quality is too low, you might want to handle it here
826 | if quality < 10:
827 | print("The image could not be compressed enough to meet the size requirements.")
828 |
829 |
830 | def generate_image(text: str, output_path: str, title_image: Optional[str] = None):
831 | """生成图片主函数 - 修复彩色emoji渲染"""
832 | try:
833 | width = 720
834 | current_dir = os.path.dirname(os.path.abspath(__file__))
835 | font_paths = {
836 | 'regular': os.path.join(current_dir, "msyh.ttc"),
837 | 'bold': os.path.join(current_dir, "msyhbd.ttc"),
838 | 'emoji': os.path.join(current_dir, "TwitterColorEmoji.ttf") # 或其他彩色emoji字体
839 | }
840 |
841 | # 验证字体文件
842 | for font_type, path in font_paths.items():
843 | if not os.path.exists(path):
844 | raise FileNotFoundError(f"Font file not found: {path}")
845 |
846 | # 初始化组件
847 | font_manager = FontManager(font_paths)
848 | rect_width = width - 80
849 | max_content_width = rect_width - 80
850 | parser = MarkdownParser()
851 | renderer = TextRenderer(font_manager, max_content_width)
852 |
853 | # 解析文本
854 | segments = parser.parse(text)
855 | processed_lines = []
856 |
857 | for segment in segments:
858 | available_width = max_content_width - segment.style.indent
859 | if segment.text.strip():
860 | lines = renderer.split_text_to_lines(segment, available_width)
861 | processed_lines.extend(lines)
862 | else:
863 | processed_lines.append(ProcessedLine(
864 | text='',
865 | style=segment.style,
866 | height=0,
867 | line_count=1
868 | ))
869 |
870 | # 计算高度
871 | title_height = 0
872 | if title_image:
873 | try:
874 | with Image.open(title_image) as img:
875 | aspect_ratio = img.height / img.width
876 | title_height = int((rect_width - 40) * aspect_ratio) + 40
877 | except Exception as e:
878 | print(f"Title image processing error: {e}")
879 |
880 | content_height = renderer.calculate_height(processed_lines)
881 | rect_height = content_height + title_height
882 | rect_x = (width - rect_width) // 2
883 | rect_y = 40
884 | total_height = rect_height + 80
885 |
886 | # 创建RGBA背景
887 | background = create_gradient_background(width, total_height)
888 | draw = ImageDraw.Draw(background)
889 |
890 | # 获取主题颜色
891 | background_color, text_color, is_dark_theme = get_theme_colors()
892 | if len(background_color) == 3:
893 | background_color = background_color + (128,) # 添加alpha通道
894 |
895 | # 创建卡片背景
896 | create_rounded_rectangle(
897 | background, rect_x, rect_y, rect_width, rect_height,
898 | radius=30, bg_color=background_color
899 | )
900 |
901 | # 绘制内容
902 | current_y = rect_y + 30
903 | if title_image:
904 | current_y = add_title_image(background, title_image, rect_x, rect_y, rect_width)
905 |
906 | # 逐字符绘制文本
907 | for i, line in enumerate(processed_lines):
908 | if not line.text.strip():
909 | if i < len(processed_lines) - 1 and any(l.text.strip() for l in processed_lines[i + 1:]):
910 | current_y += line.style.line_spacing
911 | continue
912 |
913 | x = rect_x + 40 + line.style.indent
914 | current_x = x
915 |
916 | # 逐字符渲染
917 | for char in line.text:
918 | if emoji.is_emoji(char):
919 | # emoji字体渲染
920 | emoji_font = font_manager.fonts['emoji_30']
921 | bbox = draw.textbbox((current_x, current_y), char, font=emoji_font)
922 | # 使用RGBA模式绘制emoji
923 | draw.text((current_x, current_y), char, font=emoji_font, embedded_color=True)
924 | current_x += bbox[2] - bbox[0]
925 | else:
926 | # 普通文字渲染
927 | font = font_manager.get_font(line.style)
928 | bbox = draw.textbbox((current_x, current_y), char, font=font)
929 | draw.text((current_x, current_y), char, font=font, fill=text_color)
930 | current_x += bbox[2] - bbox[0]
931 |
932 | if i < len(processed_lines) - 1:
933 | current_y += line.height + line.style.line_spacing
934 | else:
935 | current_y += line.height
936 |
937 | # 直接保存为PNG,保持RGBA模式
938 | background = background.convert('RGB')
939 | background.save(output_path, "PNG", optimize=False, compress_level=0)
940 |
941 | except Exception as e:
942 | print(f"Error generating image: {e}")
943 | raise
944 |
--------------------------------------------------------------------------------
/msyh.ttc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LargeCupPanda/Text2Card/254ef4d41a54434961b79f2ec53ff1ffc02b967a/msyh.ttc
--------------------------------------------------------------------------------
/msyhbd.ttc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LargeCupPanda/Text2Card/254ef4d41a54434961b79f2ec53ff1ffc02b967a/msyhbd.ttc
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # Flask 相关
2 | Flask>=2.3.2
3 | Flask-CORS>=4.0.0
4 | Werkzeug>=2.3.7
5 | Jinja2>=3.1.2
6 | itsdangerous>=2.1.2
7 | click>=8.1.6
8 |
9 | # 图像处理
10 | Pillow>=10.0.0
11 |
12 | # emoji 支持
13 | emoji>=2.8.0
14 |
15 | # 正则表达式
16 | regex>=2023.10.3
17 |
18 | # 日期时间处理
19 | python-dateutil>=2.8.2
20 |
21 | # 类型注解支持
22 | typing-extensions>=4.8.0
23 |
24 | # 其他常用库
25 | requests>=2.31.0
26 | python-dotenv>=1.0.0
27 | gunicorn>=20.1.0
--------------------------------------------------------------------------------