├── .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 | 19 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | ![示例卡片](./assets/example_card.png) 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 | ![Example Card](./assets/example_card.png) 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 --------------------------------------------------------------------------------