AstrBot 3.5.6__py3-none-any.whl → 4.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrbot/api/__init__.py +16 -4
- astrbot/api/all.py +2 -1
- astrbot/api/event/__init__.py +5 -6
- astrbot/api/event/filter/__init__.py +37 -34
- astrbot/api/platform/__init__.py +7 -8
- astrbot/api/provider/__init__.py +8 -7
- astrbot/api/star/__init__.py +3 -4
- astrbot/api/util/__init__.py +2 -2
- astrbot/cli/__init__.py +1 -0
- astrbot/cli/__main__.py +18 -197
- astrbot/cli/commands/__init__.py +6 -0
- astrbot/cli/commands/cmd_conf.py +209 -0
- astrbot/cli/commands/cmd_init.py +56 -0
- astrbot/cli/commands/cmd_plug.py +245 -0
- astrbot/cli/commands/cmd_run.py +62 -0
- astrbot/cli/utils/__init__.py +18 -0
- astrbot/cli/utils/basic.py +76 -0
- astrbot/cli/utils/plugin.py +246 -0
- astrbot/cli/utils/version_comparator.py +90 -0
- astrbot/core/__init__.py +17 -19
- astrbot/core/agent/agent.py +14 -0
- astrbot/core/agent/handoff.py +38 -0
- astrbot/core/agent/hooks.py +30 -0
- astrbot/core/agent/mcp_client.py +385 -0
- astrbot/core/agent/message.py +175 -0
- astrbot/core/agent/response.py +14 -0
- astrbot/core/agent/run_context.py +22 -0
- astrbot/core/agent/runners/__init__.py +3 -0
- astrbot/core/agent/runners/base.py +65 -0
- astrbot/core/agent/runners/coze/coze_agent_runner.py +367 -0
- astrbot/core/agent/runners/coze/coze_api_client.py +324 -0
- astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +403 -0
- astrbot/core/agent/runners/dify/dify_agent_runner.py +336 -0
- astrbot/core/agent/runners/dify/dify_api_client.py +195 -0
- astrbot/core/agent/runners/tool_loop_agent_runner.py +400 -0
- astrbot/core/agent/tool.py +285 -0
- astrbot/core/agent/tool_executor.py +17 -0
- astrbot/core/astr_agent_context.py +19 -0
- astrbot/core/astr_agent_hooks.py +36 -0
- astrbot/core/astr_agent_run_util.py +80 -0
- astrbot/core/astr_agent_tool_exec.py +246 -0
- astrbot/core/astrbot_config_mgr.py +275 -0
- astrbot/core/config/__init__.py +2 -2
- astrbot/core/config/astrbot_config.py +60 -20
- astrbot/core/config/default.py +1972 -453
- astrbot/core/config/i18n_utils.py +110 -0
- astrbot/core/conversation_mgr.py +285 -75
- astrbot/core/core_lifecycle.py +167 -62
- astrbot/core/db/__init__.py +305 -102
- astrbot/core/db/migration/helper.py +69 -0
- astrbot/core/db/migration/migra_3_to_4.py +357 -0
- astrbot/core/db/migration/migra_45_to_46.py +44 -0
- astrbot/core/db/migration/migra_webchat_session.py +131 -0
- astrbot/core/db/migration/shared_preferences_v3.py +48 -0
- astrbot/core/db/migration/sqlite_v3.py +497 -0
- astrbot/core/db/po.py +259 -55
- astrbot/core/db/sqlite.py +773 -528
- astrbot/core/db/vec_db/base.py +73 -0
- astrbot/core/db/vec_db/faiss_impl/__init__.py +3 -0
- astrbot/core/db/vec_db/faiss_impl/document_storage.py +392 -0
- astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +93 -0
- astrbot/core/db/vec_db/faiss_impl/sqlite_init.sql +17 -0
- astrbot/core/db/vec_db/faiss_impl/vec_db.py +204 -0
- astrbot/core/event_bus.py +26 -22
- astrbot/core/exceptions.py +9 -0
- astrbot/core/file_token_service.py +98 -0
- astrbot/core/initial_loader.py +19 -10
- astrbot/core/knowledge_base/chunking/__init__.py +9 -0
- astrbot/core/knowledge_base/chunking/base.py +25 -0
- astrbot/core/knowledge_base/chunking/fixed_size.py +59 -0
- astrbot/core/knowledge_base/chunking/recursive.py +161 -0
- astrbot/core/knowledge_base/kb_db_sqlite.py +301 -0
- astrbot/core/knowledge_base/kb_helper.py +642 -0
- astrbot/core/knowledge_base/kb_mgr.py +330 -0
- astrbot/core/knowledge_base/models.py +120 -0
- astrbot/core/knowledge_base/parsers/__init__.py +13 -0
- astrbot/core/knowledge_base/parsers/base.py +51 -0
- astrbot/core/knowledge_base/parsers/markitdown_parser.py +26 -0
- astrbot/core/knowledge_base/parsers/pdf_parser.py +101 -0
- astrbot/core/knowledge_base/parsers/text_parser.py +42 -0
- astrbot/core/knowledge_base/parsers/url_parser.py +103 -0
- astrbot/core/knowledge_base/parsers/util.py +13 -0
- astrbot/core/knowledge_base/prompts.py +65 -0
- astrbot/core/knowledge_base/retrieval/__init__.py +14 -0
- astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
- astrbot/core/knowledge_base/retrieval/manager.py +276 -0
- astrbot/core/knowledge_base/retrieval/rank_fusion.py +142 -0
- astrbot/core/knowledge_base/retrieval/sparse_retriever.py +136 -0
- astrbot/core/log.py +21 -15
- astrbot/core/message/components.py +413 -287
- astrbot/core/message/message_event_result.py +35 -24
- astrbot/core/persona_mgr.py +192 -0
- astrbot/core/pipeline/__init__.py +14 -14
- astrbot/core/pipeline/content_safety_check/stage.py +13 -9
- astrbot/core/pipeline/content_safety_check/strategies/__init__.py +1 -2
- astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py +13 -14
- astrbot/core/pipeline/content_safety_check/strategies/keywords.py +2 -1
- astrbot/core/pipeline/content_safety_check/strategies/strategy.py +6 -6
- astrbot/core/pipeline/context.py +7 -1
- astrbot/core/pipeline/context_utils.py +107 -0
- astrbot/core/pipeline/preprocess_stage/stage.py +63 -36
- astrbot/core/pipeline/process_stage/method/agent_request.py +48 -0
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +464 -0
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +202 -0
- astrbot/core/pipeline/process_stage/method/star_request.py +26 -32
- astrbot/core/pipeline/process_stage/stage.py +21 -15
- astrbot/core/pipeline/process_stage/utils.py +125 -0
- astrbot/core/pipeline/rate_limit_check/stage.py +34 -36
- astrbot/core/pipeline/respond/stage.py +142 -101
- astrbot/core/pipeline/result_decorate/stage.py +124 -57
- astrbot/core/pipeline/scheduler.py +21 -16
- astrbot/core/pipeline/session_status_check/stage.py +37 -0
- astrbot/core/pipeline/stage.py +11 -76
- astrbot/core/pipeline/waking_check/stage.py +69 -33
- astrbot/core/pipeline/whitelist_check/stage.py +10 -7
- astrbot/core/platform/__init__.py +6 -6
- astrbot/core/platform/astr_message_event.py +107 -129
- astrbot/core/platform/astrbot_message.py +32 -12
- astrbot/core/platform/manager.py +62 -18
- astrbot/core/platform/message_session.py +30 -0
- astrbot/core/platform/platform.py +16 -24
- astrbot/core/platform/platform_metadata.py +9 -4
- astrbot/core/platform/register.py +12 -7
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +136 -60
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +126 -46
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +63 -31
- astrbot/core/platform/sources/dingtalk/dingtalk_event.py +30 -26
- astrbot/core/platform/sources/discord/client.py +129 -0
- astrbot/core/platform/sources/discord/components.py +139 -0
- astrbot/core/platform/sources/discord/discord_platform_adapter.py +473 -0
- astrbot/core/platform/sources/discord/discord_platform_event.py +313 -0
- astrbot/core/platform/sources/lark/lark_adapter.py +27 -18
- astrbot/core/platform/sources/lark/lark_event.py +39 -13
- astrbot/core/platform/sources/misskey/misskey_adapter.py +770 -0
- astrbot/core/platform/sources/misskey/misskey_api.py +964 -0
- astrbot/core/platform/sources/misskey/misskey_event.py +163 -0
- astrbot/core/platform/sources/misskey/misskey_utils.py +550 -0
- astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +149 -33
- astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +41 -26
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +36 -17
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py +3 -1
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +14 -8
- astrbot/core/platform/sources/satori/satori_adapter.py +792 -0
- astrbot/core/platform/sources/satori/satori_event.py +432 -0
- astrbot/core/platform/sources/slack/client.py +164 -0
- astrbot/core/platform/sources/slack/slack_adapter.py +416 -0
- astrbot/core/platform/sources/slack/slack_event.py +253 -0
- astrbot/core/platform/sources/telegram/tg_adapter.py +100 -43
- astrbot/core/platform/sources/telegram/tg_event.py +136 -36
- astrbot/core/platform/sources/webchat/webchat_adapter.py +72 -22
- astrbot/core/platform/sources/webchat/webchat_event.py +46 -22
- astrbot/core/platform/sources/webchat/webchat_queue_mgr.py +35 -0
- astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +926 -0
- astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py +178 -0
- astrbot/core/platform/sources/wechatpadpro/xml_data_parser.py +159 -0
- astrbot/core/platform/sources/wecom/wecom_adapter.py +169 -27
- astrbot/core/platform/sources/wecom/wecom_event.py +162 -77
- astrbot/core/platform/sources/wecom/wecom_kf.py +279 -0
- astrbot/core/platform/sources/wecom/wecom_kf_message.py +196 -0
- astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py +297 -0
- astrbot/core/platform/sources/wecom_ai_bot/__init__.py +15 -0
- astrbot/core/platform/sources/wecom_ai_bot/ierror.py +19 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +472 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py +417 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +152 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +153 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +168 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_utils.py +209 -0
- astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +306 -0
- astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +186 -0
- astrbot/core/platform_message_history_mgr.py +49 -0
- astrbot/core/provider/__init__.py +2 -3
- astrbot/core/provider/entites.py +8 -8
- astrbot/core/provider/entities.py +154 -98
- astrbot/core/provider/func_tool_manager.py +446 -458
- astrbot/core/provider/manager.py +345 -207
- astrbot/core/provider/provider.py +188 -73
- astrbot/core/provider/register.py +9 -7
- astrbot/core/provider/sources/anthropic_source.py +295 -115
- astrbot/core/provider/sources/azure_tts_source.py +224 -0
- astrbot/core/provider/sources/bailian_rerank_source.py +236 -0
- astrbot/core/provider/sources/dashscope_tts.py +138 -14
- astrbot/core/provider/sources/edge_tts_source.py +24 -19
- astrbot/core/provider/sources/fishaudio_tts_api_source.py +58 -13
- astrbot/core/provider/sources/gemini_embedding_source.py +61 -0
- astrbot/core/provider/sources/gemini_source.py +310 -132
- astrbot/core/provider/sources/gemini_tts_source.py +81 -0
- astrbot/core/provider/sources/groq_source.py +15 -0
- astrbot/core/provider/sources/gsv_selfhosted_source.py +151 -0
- astrbot/core/provider/sources/gsvi_tts_source.py +14 -7
- astrbot/core/provider/sources/minimax_tts_api_source.py +159 -0
- astrbot/core/provider/sources/openai_embedding_source.py +40 -0
- astrbot/core/provider/sources/openai_source.py +241 -145
- astrbot/core/provider/sources/openai_tts_api_source.py +18 -7
- astrbot/core/provider/sources/sensevoice_selfhosted_source.py +13 -11
- astrbot/core/provider/sources/vllm_rerank_source.py +71 -0
- astrbot/core/provider/sources/volcengine_tts.py +115 -0
- astrbot/core/provider/sources/whisper_api_source.py +18 -13
- astrbot/core/provider/sources/whisper_selfhosted_source.py +19 -12
- astrbot/core/provider/sources/xinference_rerank_source.py +116 -0
- astrbot/core/provider/sources/xinference_stt_provider.py +197 -0
- astrbot/core/provider/sources/zhipu_source.py +6 -73
- astrbot/core/star/__init__.py +43 -11
- astrbot/core/star/config.py +17 -18
- astrbot/core/star/context.py +362 -138
- astrbot/core/star/filter/__init__.py +4 -3
- astrbot/core/star/filter/command.py +111 -35
- astrbot/core/star/filter/command_group.py +46 -34
- astrbot/core/star/filter/custom_filter.py +6 -5
- astrbot/core/star/filter/event_message_type.py +4 -2
- astrbot/core/star/filter/permission.py +4 -2
- astrbot/core/star/filter/platform_adapter_type.py +45 -12
- astrbot/core/star/filter/regex.py +4 -2
- astrbot/core/star/register/__init__.py +19 -15
- astrbot/core/star/register/star.py +41 -13
- astrbot/core/star/register/star_handler.py +236 -86
- astrbot/core/star/session_llm_manager.py +280 -0
- astrbot/core/star/session_plugin_manager.py +170 -0
- astrbot/core/star/star.py +36 -43
- astrbot/core/star/star_handler.py +47 -85
- astrbot/core/star/star_manager.py +442 -260
- astrbot/core/star/star_tools.py +167 -45
- astrbot/core/star/updator.py +17 -20
- astrbot/core/umop_config_router.py +106 -0
- astrbot/core/updator.py +38 -13
- astrbot/core/utils/astrbot_path.py +39 -0
- astrbot/core/utils/command_parser.py +1 -1
- astrbot/core/utils/io.py +119 -60
- astrbot/core/utils/log_pipe.py +1 -1
- astrbot/core/utils/metrics.py +11 -10
- astrbot/core/utils/migra_helper.py +73 -0
- astrbot/core/utils/path_util.py +63 -62
- astrbot/core/utils/pip_installer.py +37 -15
- astrbot/core/utils/session_lock.py +29 -0
- astrbot/core/utils/session_waiter.py +19 -20
- astrbot/core/utils/shared_preferences.py +174 -34
- astrbot/core/utils/t2i/__init__.py +4 -1
- astrbot/core/utils/t2i/local_strategy.py +386 -238
- astrbot/core/utils/t2i/network_strategy.py +109 -49
- astrbot/core/utils/t2i/renderer.py +29 -14
- astrbot/core/utils/t2i/template/astrbot_powershell.html +184 -0
- astrbot/core/utils/t2i/template_manager.py +111 -0
- astrbot/core/utils/tencent_record_helper.py +115 -1
- astrbot/core/utils/version_comparator.py +10 -13
- astrbot/core/zip_updator.py +112 -65
- astrbot/dashboard/routes/__init__.py +20 -13
- astrbot/dashboard/routes/auth.py +20 -9
- astrbot/dashboard/routes/chat.py +297 -141
- astrbot/dashboard/routes/config.py +652 -55
- astrbot/dashboard/routes/conversation.py +107 -37
- astrbot/dashboard/routes/file.py +26 -0
- astrbot/dashboard/routes/knowledge_base.py +1244 -0
- astrbot/dashboard/routes/log.py +27 -2
- astrbot/dashboard/routes/persona.py +202 -0
- astrbot/dashboard/routes/plugin.py +197 -139
- astrbot/dashboard/routes/route.py +27 -7
- astrbot/dashboard/routes/session_management.py +354 -0
- astrbot/dashboard/routes/stat.py +85 -18
- astrbot/dashboard/routes/static_file.py +5 -2
- astrbot/dashboard/routes/t2i.py +233 -0
- astrbot/dashboard/routes/tools.py +184 -120
- astrbot/dashboard/routes/update.py +59 -36
- astrbot/dashboard/server.py +96 -36
- astrbot/dashboard/utils.py +165 -0
- astrbot-4.7.0.dist-info/METADATA +294 -0
- astrbot-4.7.0.dist-info/RECORD +274 -0
- {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/WHEEL +1 -1
- astrbot/core/db/plugin/sqlite_impl.py +0 -112
- astrbot/core/db/sqlite_init.sql +0 -50
- astrbot/core/pipeline/platform_compatibility/stage.py +0 -56
- astrbot/core/pipeline/process_stage/method/llm_request.py +0 -606
- astrbot/core/platform/sources/gewechat/client.py +0 -806
- astrbot/core/platform/sources/gewechat/downloader.py +0 -55
- astrbot/core/platform/sources/gewechat/gewechat_event.py +0 -255
- astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py +0 -103
- astrbot/core/platform/sources/gewechat/xml_data_parser.py +0 -110
- astrbot/core/provider/sources/dashscope_source.py +0 -203
- astrbot/core/provider/sources/dify_source.py +0 -281
- astrbot/core/provider/sources/llmtuner_source.py +0 -132
- astrbot/core/rag/embedding/openai_source.py +0 -20
- astrbot/core/rag/knowledge_db_mgr.py +0 -94
- astrbot/core/rag/store/__init__.py +0 -9
- astrbot/core/rag/store/chroma_db.py +0 -42
- astrbot/core/utils/dify_api_client.py +0 -152
- astrbot-3.5.6.dist-info/METADATA +0 -249
- astrbot-3.5.6.dist-info/RECORD +0 -158
- {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/entry_points.txt +0 -0
- {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import re
|
|
2
|
+
import os
|
|
2
3
|
import aiohttp
|
|
3
4
|
import ssl
|
|
4
5
|
import certifi
|
|
@@ -10,38 +11,40 @@ from astrbot.core.config import VERSION
|
|
|
10
11
|
from . import RenderStrategy
|
|
11
12
|
from PIL import ImageFont, Image, ImageDraw
|
|
12
13
|
from astrbot.core.utils.io import save_temp_img
|
|
14
|
+
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
class FontManager:
|
|
16
18
|
"""字体管理类,负责加载和缓存字体"""
|
|
17
|
-
|
|
19
|
+
|
|
18
20
|
_font_cache = {}
|
|
19
|
-
|
|
21
|
+
|
|
20
22
|
@classmethod
|
|
21
23
|
def get_font(cls, size: int) -> ImageFont.FreeTypeFont:
|
|
22
24
|
"""获取指定大小的字体,优先从缓存获取"""
|
|
23
25
|
if size in cls._font_cache:
|
|
24
26
|
return cls._font_cache[size]
|
|
25
|
-
|
|
27
|
+
|
|
26
28
|
# 首先尝试加载自定义字体
|
|
27
29
|
try:
|
|
28
|
-
|
|
30
|
+
font_path = os.path.join(get_astrbot_data_path(), "font.ttf")
|
|
31
|
+
font = ImageFont.truetype(font_path, size)
|
|
29
32
|
cls._font_cache[size] = font
|
|
30
33
|
return font
|
|
31
34
|
except Exception:
|
|
32
35
|
pass
|
|
33
|
-
|
|
36
|
+
|
|
34
37
|
# 跨平台常见字体列表
|
|
35
38
|
fonts = [
|
|
36
|
-
"msyh.ttc",
|
|
39
|
+
"msyh.ttc", # Windows
|
|
37
40
|
"NotoSansCJK-Regular.ttc", # Linux
|
|
38
|
-
"msyhbd.ttc",
|
|
39
|
-
"PingFang.ttc",
|
|
40
|
-
"Heiti.ttc",
|
|
41
|
-
"Arial.ttf",
|
|
42
|
-
"DejaVuSans.ttf",
|
|
41
|
+
"msyhbd.ttc", # Windows
|
|
42
|
+
"PingFang.ttc", # macOS
|
|
43
|
+
"Heiti.ttc", # macOS
|
|
44
|
+
"Arial.ttf", # 通用
|
|
45
|
+
"DejaVuSans.ttf", # Linux
|
|
43
46
|
]
|
|
44
|
-
|
|
47
|
+
|
|
45
48
|
for font_name in fonts:
|
|
46
49
|
try:
|
|
47
50
|
font = ImageFont.truetype(font_name, size)
|
|
@@ -49,7 +52,7 @@ class FontManager:
|
|
|
49
52
|
return font
|
|
50
53
|
except Exception:
|
|
51
54
|
continue
|
|
52
|
-
|
|
55
|
+
|
|
53
56
|
# 如果所有字体都失败,使用默认字体
|
|
54
57
|
try:
|
|
55
58
|
default_font = ImageFont.load_default()
|
|
@@ -61,24 +64,30 @@ class FontManager:
|
|
|
61
64
|
|
|
62
65
|
class TextMeasurer:
|
|
63
66
|
"""测量文本尺寸的工具类"""
|
|
64
|
-
|
|
67
|
+
|
|
65
68
|
@staticmethod
|
|
66
69
|
def get_text_size(text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
|
|
67
70
|
"""获取文本的尺寸"""
|
|
68
71
|
try:
|
|
69
72
|
# PIL 9.0.0 以上版本
|
|
70
|
-
return
|
|
73
|
+
return (
|
|
74
|
+
font.getbbox(text)[2:]
|
|
75
|
+
if hasattr(font, "getbbox")
|
|
76
|
+
else font.getsize(text)
|
|
77
|
+
)
|
|
71
78
|
except Exception:
|
|
72
79
|
# 兼容旧版本
|
|
73
80
|
return font.getsize(text)
|
|
74
81
|
|
|
75
82
|
@staticmethod
|
|
76
|
-
def split_text_to_fit_width(
|
|
83
|
+
def split_text_to_fit_width(
|
|
84
|
+
text: str, font: ImageFont.FreeTypeFont, max_width: int
|
|
85
|
+
) -> List[str]:
|
|
77
86
|
"""将文本拆分为多行,确保每行不超过指定宽度"""
|
|
78
87
|
lines = []
|
|
79
88
|
if not text:
|
|
80
89
|
return lines
|
|
81
|
-
|
|
90
|
+
|
|
82
91
|
remaining_text = text
|
|
83
92
|
while remaining_text:
|
|
84
93
|
# 如果文本宽度小于最大宽度,直接添加
|
|
@@ -86,7 +95,7 @@ class TextMeasurer:
|
|
|
86
95
|
if text_width <= max_width:
|
|
87
96
|
lines.append(remaining_text)
|
|
88
97
|
break
|
|
89
|
-
|
|
98
|
+
|
|
90
99
|
# 尝试逐字计算能放入当前行的最多字符
|
|
91
100
|
for i in range(len(remaining_text), 0, -1):
|
|
92
101
|
width = TextMeasurer.get_text_size(remaining_text[:i], font)[0]
|
|
@@ -98,69 +107,99 @@ class TextMeasurer:
|
|
|
98
107
|
# 如果单个字符都放不下,强制放一个字符
|
|
99
108
|
lines.append(remaining_text[0])
|
|
100
109
|
remaining_text = remaining_text[1:]
|
|
101
|
-
|
|
110
|
+
|
|
102
111
|
return lines
|
|
103
112
|
|
|
104
113
|
|
|
105
114
|
class MarkdownElement(ABC):
|
|
106
115
|
"""Markdown元素的基类"""
|
|
107
|
-
|
|
116
|
+
|
|
108
117
|
def __init__(self, content: str):
|
|
109
118
|
self.content = content
|
|
110
|
-
|
|
119
|
+
|
|
111
120
|
@abstractmethod
|
|
112
121
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
113
122
|
"""计算元素的高度"""
|
|
114
123
|
pass
|
|
115
|
-
|
|
124
|
+
|
|
116
125
|
@abstractmethod
|
|
117
|
-
def render(
|
|
126
|
+
def render(
|
|
127
|
+
self,
|
|
128
|
+
image: Image.Image,
|
|
129
|
+
draw: ImageDraw.Draw,
|
|
130
|
+
x: int,
|
|
131
|
+
y: int,
|
|
132
|
+
image_width: int,
|
|
133
|
+
font_size: int,
|
|
134
|
+
) -> int:
|
|
118
135
|
"""渲染元素到图像,返回新的y坐标"""
|
|
119
136
|
pass
|
|
120
137
|
|
|
121
138
|
|
|
122
139
|
class TextElement(MarkdownElement):
|
|
123
140
|
"""普通文本元素"""
|
|
124
|
-
|
|
141
|
+
|
|
125
142
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
126
143
|
if not self.content.strip():
|
|
127
144
|
return 10 # 空行高度
|
|
128
|
-
|
|
145
|
+
|
|
129
146
|
font = FontManager.get_font(font_size)
|
|
130
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
147
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
148
|
+
self.content, font, image_width - 20
|
|
149
|
+
)
|
|
131
150
|
return len(lines) * (font_size + 8)
|
|
132
|
-
|
|
133
|
-
def render(
|
|
151
|
+
|
|
152
|
+
def render(
|
|
153
|
+
self,
|
|
154
|
+
image: Image.Image,
|
|
155
|
+
draw: ImageDraw.Draw,
|
|
156
|
+
x: int,
|
|
157
|
+
y: int,
|
|
158
|
+
image_width: int,
|
|
159
|
+
font_size: int,
|
|
160
|
+
) -> int:
|
|
134
161
|
if not self.content.strip():
|
|
135
162
|
return y + 10 # 空行
|
|
136
|
-
|
|
163
|
+
|
|
137
164
|
font = FontManager.get_font(font_size)
|
|
138
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
139
|
-
|
|
165
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
166
|
+
self.content, font, image_width - 20
|
|
167
|
+
)
|
|
168
|
+
|
|
140
169
|
for line in lines:
|
|
141
170
|
draw.text((x, y), line, font=font, fill=(0, 0, 0))
|
|
142
171
|
y += font_size + 8
|
|
143
|
-
|
|
172
|
+
|
|
144
173
|
return y
|
|
145
174
|
|
|
146
175
|
|
|
147
176
|
class BoldTextElement(MarkdownElement):
|
|
148
177
|
"""粗体文本元素"""
|
|
149
|
-
|
|
178
|
+
|
|
150
179
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
151
180
|
font = FontManager.get_font(font_size)
|
|
152
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
181
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
182
|
+
self.content, font, image_width - 20
|
|
183
|
+
)
|
|
153
184
|
return len(lines) * (font_size + 8)
|
|
154
|
-
|
|
155
|
-
def render(
|
|
185
|
+
|
|
186
|
+
def render(
|
|
187
|
+
self,
|
|
188
|
+
image: Image.Image,
|
|
189
|
+
draw: ImageDraw.Draw,
|
|
190
|
+
x: int,
|
|
191
|
+
y: int,
|
|
192
|
+
image_width: int,
|
|
193
|
+
font_size: int,
|
|
194
|
+
) -> int:
|
|
156
195
|
# 尝试使用粗体字体,如果没有则绘制两次模拟粗体效果
|
|
157
196
|
try:
|
|
158
197
|
bold_fonts = [
|
|
159
|
-
"msyhbd.ttc",
|
|
198
|
+
"msyhbd.ttc", # 微软雅黑粗体 (Windows)
|
|
160
199
|
"Arial-Bold.ttf", # Arial粗体
|
|
161
200
|
"DejaVuSans-Bold.ttf", # Linux粗体
|
|
162
201
|
]
|
|
163
|
-
|
|
202
|
+
|
|
164
203
|
bold_font = None
|
|
165
204
|
for font_name in bold_fonts:
|
|
166
205
|
try:
|
|
@@ -168,48 +207,64 @@ class BoldTextElement(MarkdownElement):
|
|
|
168
207
|
break
|
|
169
208
|
except Exception:
|
|
170
209
|
continue
|
|
171
|
-
|
|
210
|
+
|
|
172
211
|
if bold_font:
|
|
173
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
212
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
213
|
+
self.content, bold_font, image_width - 20
|
|
214
|
+
)
|
|
174
215
|
for line in lines:
|
|
175
216
|
draw.text((x, y), line, font=bold_font, fill=(0, 0, 0))
|
|
176
217
|
y += font_size + 8
|
|
177
218
|
else:
|
|
178
219
|
# 如果没有粗体字体,则绘制两次文本轻微偏移以模拟粗体
|
|
179
220
|
font = FontManager.get_font(font_size)
|
|
180
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
221
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
222
|
+
self.content, font, image_width - 20
|
|
223
|
+
)
|
|
181
224
|
for line in lines:
|
|
182
225
|
draw.text((x, y), line, font=font, fill=(0, 0, 0))
|
|
183
|
-
draw.text((x+1, y), line, font=font, fill=(0, 0, 0))
|
|
226
|
+
draw.text((x + 1, y), line, font=font, fill=(0, 0, 0))
|
|
184
227
|
y += font_size + 8
|
|
185
228
|
except Exception:
|
|
186
229
|
# 兜底方案:使用普通字体
|
|
187
230
|
font = FontManager.get_font(font_size)
|
|
188
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
231
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
232
|
+
self.content, font, image_width - 20
|
|
233
|
+
)
|
|
189
234
|
for line in lines:
|
|
190
235
|
draw.text((x, y), line, font=font, fill=(0, 0, 0))
|
|
191
236
|
y += font_size + 8
|
|
192
|
-
|
|
237
|
+
|
|
193
238
|
return y
|
|
194
239
|
|
|
195
240
|
|
|
196
241
|
class ItalicTextElement(MarkdownElement):
|
|
197
242
|
"""斜体文本元素"""
|
|
198
|
-
|
|
243
|
+
|
|
199
244
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
200
245
|
font = FontManager.get_font(font_size)
|
|
201
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
246
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
247
|
+
self.content, font, image_width - 20
|
|
248
|
+
)
|
|
202
249
|
return len(lines) * (font_size + 8)
|
|
203
|
-
|
|
204
|
-
def render(
|
|
250
|
+
|
|
251
|
+
def render(
|
|
252
|
+
self,
|
|
253
|
+
image: Image.Image,
|
|
254
|
+
draw: ImageDraw.Draw,
|
|
255
|
+
x: int,
|
|
256
|
+
y: int,
|
|
257
|
+
image_width: int,
|
|
258
|
+
font_size: int,
|
|
259
|
+
) -> int:
|
|
205
260
|
# 尝试使用斜体字体,如果没有则使用倾斜变换模拟斜体效果
|
|
206
261
|
try:
|
|
207
262
|
italic_fonts = [
|
|
208
|
-
"msyhi.ttc",
|
|
263
|
+
"msyhi.ttc", # 微软雅黑斜体 (Windows)
|
|
209
264
|
"Arial-Italic.ttf", # Arial斜体
|
|
210
265
|
"DejaVuSans-Oblique.ttf", # Linux斜体
|
|
211
266
|
]
|
|
212
|
-
|
|
267
|
+
|
|
213
268
|
italic_font = None
|
|
214
269
|
for font_name in italic_fonts:
|
|
215
270
|
try:
|
|
@@ -217,312 +272,388 @@ class ItalicTextElement(MarkdownElement):
|
|
|
217
272
|
break
|
|
218
273
|
except Exception:
|
|
219
274
|
continue
|
|
220
|
-
|
|
275
|
+
|
|
221
276
|
if italic_font:
|
|
222
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
277
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
278
|
+
self.content, italic_font, image_width - 20
|
|
279
|
+
)
|
|
223
280
|
for line in lines:
|
|
224
281
|
draw.text((x, y), line, font=italic_font, fill=(0, 0, 0))
|
|
225
282
|
y += font_size + 8
|
|
226
283
|
else:
|
|
227
284
|
# 如果没有斜体字体,使用变换
|
|
228
285
|
font = FontManager.get_font(font_size)
|
|
229
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
230
|
-
|
|
286
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
287
|
+
self.content, font, image_width - 20
|
|
288
|
+
)
|
|
289
|
+
|
|
231
290
|
for line in lines:
|
|
232
291
|
# 先创建一个临时图像用于倾斜处理
|
|
233
292
|
text_width, text_height = TextMeasurer.get_text_size(line, font)
|
|
234
|
-
text_img = Image.new(
|
|
293
|
+
text_img = Image.new(
|
|
294
|
+
"RGBA", (text_width + 20, text_height + 10), (0, 0, 0, 0)
|
|
295
|
+
)
|
|
235
296
|
text_draw = ImageDraw.Draw(text_img)
|
|
236
297
|
text_draw.text((0, 0), line, font=font, fill=(0, 0, 0, 255))
|
|
237
|
-
|
|
298
|
+
|
|
238
299
|
# 倾斜变换,使用仿射变换实现斜体效果
|
|
239
300
|
# 变换矩阵: [1, 0.2, 0, 0, 1, 0]
|
|
240
301
|
italic_img = text_img.transform(
|
|
241
|
-
text_img.size,
|
|
242
|
-
Image.AFFINE,
|
|
243
|
-
(1, 0.2, 0, 0, 1, 0),
|
|
244
|
-
Image.BICUBIC
|
|
302
|
+
text_img.size, Image.AFFINE, (1, 0.2, 0, 0, 1, 0), Image.BICUBIC
|
|
245
303
|
)
|
|
246
|
-
|
|
304
|
+
|
|
247
305
|
# 粘贴到原图像
|
|
248
306
|
image.paste(italic_img, (x, y), italic_img)
|
|
249
307
|
y += font_size + 8
|
|
250
308
|
except Exception:
|
|
251
309
|
# 兜底方案:使用普通字体
|
|
252
310
|
font = FontManager.get_font(font_size)
|
|
253
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
311
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
312
|
+
self.content, font, image_width - 20
|
|
313
|
+
)
|
|
254
314
|
for line in lines:
|
|
255
315
|
draw.text((x, y), line, font=font, fill=(0, 0, 0))
|
|
256
316
|
y += font_size + 8
|
|
257
|
-
|
|
317
|
+
|
|
258
318
|
return y
|
|
259
319
|
|
|
260
320
|
|
|
261
321
|
class UnderlineTextElement(MarkdownElement):
|
|
262
322
|
"""下划线文本元素"""
|
|
263
|
-
|
|
323
|
+
|
|
264
324
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
265
325
|
font = FontManager.get_font(font_size)
|
|
266
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
326
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
327
|
+
self.content, font, image_width - 20
|
|
328
|
+
)
|
|
267
329
|
return len(lines) * (font_size + 8)
|
|
268
|
-
|
|
269
|
-
def render(
|
|
330
|
+
|
|
331
|
+
def render(
|
|
332
|
+
self,
|
|
333
|
+
image: Image.Image,
|
|
334
|
+
draw: ImageDraw.Draw,
|
|
335
|
+
x: int,
|
|
336
|
+
y: int,
|
|
337
|
+
image_width: int,
|
|
338
|
+
font_size: int,
|
|
339
|
+
) -> int:
|
|
270
340
|
font = FontManager.get_font(font_size)
|
|
271
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
272
|
-
|
|
341
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
342
|
+
self.content, font, image_width - 20
|
|
343
|
+
)
|
|
344
|
+
|
|
273
345
|
for line in lines:
|
|
274
346
|
# 绘制文本
|
|
275
347
|
draw.text((x, y), line, font=font, fill=(0, 0, 0))
|
|
276
|
-
|
|
348
|
+
|
|
277
349
|
# 绘制下划线
|
|
278
350
|
text_width, _ = TextMeasurer.get_text_size(line, font)
|
|
279
351
|
underline_y = y + font_size + 2
|
|
280
|
-
draw.line(
|
|
281
|
-
|
|
352
|
+
draw.line(
|
|
353
|
+
(x, underline_y, x + text_width, underline_y), fill=(0, 0, 0), width=1
|
|
354
|
+
)
|
|
355
|
+
|
|
282
356
|
y += font_size + 8
|
|
283
|
-
|
|
357
|
+
|
|
284
358
|
return y
|
|
285
359
|
|
|
286
360
|
|
|
287
361
|
class StrikethroughTextElement(MarkdownElement):
|
|
288
362
|
"""删除线文本元素"""
|
|
289
|
-
|
|
363
|
+
|
|
290
364
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
291
365
|
font = FontManager.get_font(font_size)
|
|
292
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
366
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
367
|
+
self.content, font, image_width - 20
|
|
368
|
+
)
|
|
293
369
|
return len(lines) * (font_size + 8)
|
|
294
|
-
|
|
295
|
-
def render(
|
|
370
|
+
|
|
371
|
+
def render(
|
|
372
|
+
self,
|
|
373
|
+
image: Image.Image,
|
|
374
|
+
draw: ImageDraw.Draw,
|
|
375
|
+
x: int,
|
|
376
|
+
y: int,
|
|
377
|
+
image_width: int,
|
|
378
|
+
font_size: int,
|
|
379
|
+
) -> int:
|
|
296
380
|
font = FontManager.get_font(font_size)
|
|
297
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
298
|
-
|
|
381
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
382
|
+
self.content, font, image_width - 20
|
|
383
|
+
)
|
|
384
|
+
|
|
299
385
|
for line in lines:
|
|
300
386
|
# 绘制文本
|
|
301
387
|
draw.text((x, y), line, font=font, fill=(0, 0, 0))
|
|
302
|
-
|
|
388
|
+
|
|
303
389
|
# 绘制删除线
|
|
304
390
|
text_width, _ = TextMeasurer.get_text_size(line, font)
|
|
305
391
|
strike_y = y + font_size // 2
|
|
306
392
|
draw.line((x, strike_y, x + text_width, strike_y), fill=(0, 0, 0), width=1)
|
|
307
|
-
|
|
393
|
+
|
|
308
394
|
y += font_size + 8
|
|
309
|
-
|
|
395
|
+
|
|
310
396
|
return y
|
|
311
397
|
|
|
312
398
|
|
|
313
399
|
class HeaderElement(MarkdownElement):
|
|
314
400
|
"""标题元素"""
|
|
315
|
-
|
|
401
|
+
|
|
316
402
|
def __init__(self, content: str):
|
|
317
403
|
# 去除开头的 # 并计算级别
|
|
318
404
|
level = 0
|
|
319
405
|
for char in content:
|
|
320
|
-
if char ==
|
|
406
|
+
if char == "#":
|
|
321
407
|
level += 1
|
|
322
408
|
else:
|
|
323
409
|
break
|
|
324
|
-
|
|
410
|
+
|
|
325
411
|
super().__init__(content[level:].strip())
|
|
326
412
|
self.level = min(level, 6) # h1-h6
|
|
327
|
-
|
|
413
|
+
|
|
328
414
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
329
415
|
header_font_size = 42 - (self.level - 1) * 4
|
|
330
416
|
font = FontManager.get_font(header_font_size)
|
|
331
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
417
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
418
|
+
self.content, font, image_width - 20
|
|
419
|
+
)
|
|
332
420
|
return len(lines) * header_font_size + 30 # 包含上下间距和分隔线
|
|
333
|
-
|
|
334
|
-
def render(
|
|
421
|
+
|
|
422
|
+
def render(
|
|
423
|
+
self,
|
|
424
|
+
image: Image.Image,
|
|
425
|
+
draw: ImageDraw.Draw,
|
|
426
|
+
x: int,
|
|
427
|
+
y: int,
|
|
428
|
+
image_width: int,
|
|
429
|
+
font_size: int,
|
|
430
|
+
) -> int:
|
|
335
431
|
header_font_size = 42 - (self.level - 1) * 4
|
|
336
432
|
font = FontManager.get_font(header_font_size)
|
|
337
|
-
|
|
433
|
+
|
|
338
434
|
y += 10 # 上间距
|
|
339
435
|
draw.text((x, y), self.content, font=font, fill=(0, 0, 0))
|
|
340
|
-
|
|
436
|
+
|
|
341
437
|
# 添加分隔线
|
|
342
438
|
y += header_font_size + 8
|
|
343
|
-
draw.line(
|
|
344
|
-
|
|
345
|
-
fill=(230, 230, 230),
|
|
346
|
-
width=3
|
|
347
|
-
)
|
|
348
|
-
|
|
439
|
+
draw.line((x, y, image_width - 10, y), fill=(230, 230, 230), width=3)
|
|
440
|
+
|
|
349
441
|
return y + 10 # 返回包含下间距的新y坐标
|
|
350
442
|
|
|
351
443
|
|
|
352
444
|
class QuoteElement(MarkdownElement):
|
|
353
445
|
"""引用元素"""
|
|
354
|
-
|
|
446
|
+
|
|
355
447
|
def __init__(self, content: str):
|
|
356
448
|
# 去除开头的 >
|
|
357
449
|
super().__init__(content[1:].strip())
|
|
358
|
-
|
|
450
|
+
|
|
359
451
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
360
452
|
font = FontManager.get_font(font_size)
|
|
361
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
453
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
454
|
+
self.content, font, image_width - 30
|
|
455
|
+
) # 左边留出引用线的空间
|
|
362
456
|
return len(lines) * (font_size + 6) + 12 # 包含上下间距
|
|
363
|
-
|
|
364
|
-
def render(
|
|
457
|
+
|
|
458
|
+
def render(
|
|
459
|
+
self,
|
|
460
|
+
image: Image.Image,
|
|
461
|
+
draw: ImageDraw.Draw,
|
|
462
|
+
x: int,
|
|
463
|
+
y: int,
|
|
464
|
+
image_width: int,
|
|
465
|
+
font_size: int,
|
|
466
|
+
) -> int:
|
|
365
467
|
font = FontManager.get_font(font_size)
|
|
366
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
367
|
-
|
|
468
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
469
|
+
self.content, font, image_width - 30
|
|
470
|
+
)
|
|
471
|
+
|
|
368
472
|
total_height = len(lines) * (font_size + 6)
|
|
369
|
-
|
|
473
|
+
|
|
370
474
|
# 绘制引用线
|
|
371
475
|
quote_line_x = x + 3
|
|
372
476
|
draw.line(
|
|
373
477
|
(quote_line_x, y + 6, quote_line_x, y + total_height + 6),
|
|
374
478
|
fill=(180, 180, 180),
|
|
375
|
-
width=5
|
|
479
|
+
width=5,
|
|
376
480
|
)
|
|
377
|
-
|
|
481
|
+
|
|
378
482
|
# 绘制文本
|
|
379
483
|
text_x = x + 15
|
|
380
484
|
text_y = y + 6
|
|
381
485
|
for line in lines:
|
|
382
486
|
draw.text((text_x, text_y), line, font=font, fill=(180, 180, 180))
|
|
383
487
|
text_y += font_size + 6
|
|
384
|
-
|
|
488
|
+
|
|
385
489
|
return y + total_height + 12
|
|
386
490
|
|
|
387
491
|
|
|
388
492
|
class ListItemElement(MarkdownElement):
|
|
389
493
|
"""列表项元素"""
|
|
390
|
-
|
|
494
|
+
|
|
391
495
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
392
496
|
font = FontManager.get_font(font_size)
|
|
393
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
497
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
498
|
+
self.content, font, image_width - 30
|
|
499
|
+
) # 左边留出项目符号的空间
|
|
394
500
|
return len(lines) * (font_size + 6) + 16 # 包含上下间距
|
|
395
|
-
|
|
396
|
-
def render(
|
|
501
|
+
|
|
502
|
+
def render(
|
|
503
|
+
self,
|
|
504
|
+
image: Image.Image,
|
|
505
|
+
draw: ImageDraw.Draw,
|
|
506
|
+
x: int,
|
|
507
|
+
y: int,
|
|
508
|
+
image_width: int,
|
|
509
|
+
font_size: int,
|
|
510
|
+
) -> int:
|
|
397
511
|
font = FontManager.get_font(font_size)
|
|
398
|
-
lines = TextMeasurer.split_text_to_fit_width(
|
|
399
|
-
|
|
512
|
+
lines = TextMeasurer.split_text_to_fit_width(
|
|
513
|
+
self.content, font, image_width - 30
|
|
514
|
+
)
|
|
515
|
+
|
|
400
516
|
y += 8 # 上间距
|
|
401
|
-
|
|
517
|
+
|
|
402
518
|
# 绘制项目符号
|
|
403
519
|
bullet_x = x + 5
|
|
404
520
|
draw.text((bullet_x, y), "•", font=font, fill=(0, 0, 0))
|
|
405
|
-
|
|
521
|
+
|
|
406
522
|
# 绘制文本
|
|
407
523
|
text_x = x + 25
|
|
408
524
|
text_y = y
|
|
409
525
|
for line in lines:
|
|
410
526
|
draw.text((text_x, text_y), line, font=font, fill=(0, 0, 0))
|
|
411
527
|
text_y += font_size + 6
|
|
412
|
-
|
|
528
|
+
|
|
413
529
|
return text_y + 8 # 包含下间距
|
|
414
530
|
|
|
415
531
|
|
|
416
532
|
class CodeBlockElement(MarkdownElement):
|
|
417
533
|
"""代码块元素"""
|
|
418
|
-
|
|
534
|
+
|
|
419
535
|
def __init__(self, content: List[str]):
|
|
420
536
|
super().__init__("\n".join(content))
|
|
421
|
-
|
|
537
|
+
|
|
422
538
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
423
539
|
if not self.content:
|
|
424
540
|
return 40 # 空代码块的最小高度
|
|
425
|
-
|
|
541
|
+
|
|
426
542
|
font = FontManager.get_font(font_size)
|
|
427
543
|
lines = self.content.split("\n")
|
|
428
544
|
wrapped_lines = []
|
|
429
|
-
|
|
545
|
+
|
|
430
546
|
for line in lines:
|
|
431
547
|
wrapped = TextMeasurer.split_text_to_fit_width(line, font, image_width - 40)
|
|
432
548
|
wrapped_lines.extend(wrapped)
|
|
433
|
-
|
|
549
|
+
|
|
434
550
|
return len(wrapped_lines) * (font_size + 4) + 40 # 包含内边距和上下间距
|
|
435
|
-
|
|
436
|
-
def render(
|
|
551
|
+
|
|
552
|
+
def render(
|
|
553
|
+
self,
|
|
554
|
+
image: Image.Image,
|
|
555
|
+
draw: ImageDraw.Draw,
|
|
556
|
+
x: int,
|
|
557
|
+
y: int,
|
|
558
|
+
image_width: int,
|
|
559
|
+
font_size: int,
|
|
560
|
+
) -> int:
|
|
437
561
|
font = FontManager.get_font(font_size)
|
|
438
562
|
lines = self.content.split("\n")
|
|
439
563
|
wrapped_lines = []
|
|
440
|
-
|
|
564
|
+
|
|
441
565
|
for line in lines:
|
|
442
566
|
wrapped = TextMeasurer.split_text_to_fit_width(line, font, image_width - 40)
|
|
443
567
|
wrapped_lines.extend(wrapped)
|
|
444
|
-
|
|
568
|
+
|
|
445
569
|
content_height = len(wrapped_lines) * (font_size + 4)
|
|
446
570
|
total_height = content_height + 30 # 包含内边距
|
|
447
|
-
|
|
571
|
+
|
|
448
572
|
# 绘制背景
|
|
449
573
|
draw.rounded_rectangle(
|
|
450
574
|
(x, y + 5, image_width - 10, y + total_height),
|
|
451
575
|
radius=5,
|
|
452
576
|
fill=(240, 240, 240),
|
|
453
|
-
width=1
|
|
577
|
+
width=1,
|
|
454
578
|
)
|
|
455
|
-
|
|
579
|
+
|
|
456
580
|
# 绘制代码
|
|
457
581
|
text_y = y + 15
|
|
458
582
|
for line in wrapped_lines:
|
|
459
583
|
draw.text((x + 15, text_y), line, font=font, fill=(0, 0, 0))
|
|
460
584
|
text_y += font_size + 4
|
|
461
|
-
|
|
585
|
+
|
|
462
586
|
return y + total_height + 10
|
|
463
587
|
|
|
464
588
|
|
|
465
589
|
class InlineCodeElement(MarkdownElement):
|
|
466
590
|
"""行内代码元素"""
|
|
467
|
-
|
|
591
|
+
|
|
468
592
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
469
593
|
return font_size + 16 # 包含内边距和上下间距
|
|
470
|
-
|
|
471
|
-
def render(
|
|
594
|
+
|
|
595
|
+
def render(
|
|
596
|
+
self,
|
|
597
|
+
image: Image.Image,
|
|
598
|
+
draw: ImageDraw.Draw,
|
|
599
|
+
x: int,
|
|
600
|
+
y: int,
|
|
601
|
+
image_width: int,
|
|
602
|
+
font_size: int,
|
|
603
|
+
) -> int:
|
|
472
604
|
font = FontManager.get_font(font_size)
|
|
473
|
-
|
|
605
|
+
|
|
474
606
|
# 计算文本大小
|
|
475
607
|
text_width, _ = TextMeasurer.get_text_size(self.content, font)
|
|
476
608
|
text_height = font_size
|
|
477
|
-
|
|
609
|
+
|
|
478
610
|
# 绘制背景
|
|
479
611
|
padding = 4
|
|
480
612
|
draw.rounded_rectangle(
|
|
481
|
-
(
|
|
482
|
-
x,
|
|
483
|
-
y + 4,
|
|
484
|
-
x + text_width + padding * 2,
|
|
485
|
-
y + text_height + padding * 2 + 4
|
|
486
|
-
),
|
|
613
|
+
(x, y + 4, x + text_width + padding * 2, y + text_height + padding * 2 + 4),
|
|
487
614
|
radius=5,
|
|
488
615
|
fill=(230, 230, 230),
|
|
489
|
-
width=1
|
|
616
|
+
width=1,
|
|
490
617
|
)
|
|
491
|
-
|
|
618
|
+
|
|
492
619
|
# 绘制文本
|
|
493
|
-
draw.text(
|
|
494
|
-
|
|
620
|
+
draw.text(
|
|
621
|
+
(x + padding, y + padding + 4), self.content, font=font, fill=(0, 0, 0)
|
|
622
|
+
)
|
|
623
|
+
|
|
495
624
|
return y + text_height + 16 # 返回新的y坐标
|
|
496
625
|
|
|
497
626
|
|
|
498
627
|
class ImageElement(MarkdownElement):
|
|
499
628
|
"""图片元素"""
|
|
500
|
-
|
|
629
|
+
|
|
501
630
|
def __init__(self, content: str, image_url: str):
|
|
502
631
|
super().__init__(content)
|
|
503
632
|
self.image_url = image_url
|
|
504
633
|
self.image = None
|
|
505
|
-
|
|
634
|
+
|
|
506
635
|
async def load_image(self):
|
|
507
636
|
"""加载图片"""
|
|
508
637
|
try:
|
|
509
638
|
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
510
639
|
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
|
511
|
-
|
|
512
|
-
async with aiohttp.ClientSession(
|
|
640
|
+
|
|
641
|
+
async with aiohttp.ClientSession(
|
|
642
|
+
trust_env=True, connector=connector
|
|
643
|
+
) as session:
|
|
513
644
|
async with session.get(self.image_url) as resp:
|
|
514
|
-
if
|
|
645
|
+
if resp.status == 200:
|
|
515
646
|
image_data = await resp.read()
|
|
516
647
|
self.image = Image.open(BytesIO(image_data))
|
|
517
648
|
else:
|
|
518
649
|
print(f"Failed to load image: HTTP {resp.status}")
|
|
519
650
|
except Exception as e:
|
|
520
651
|
print(f"Failed to load image: {e}")
|
|
521
|
-
|
|
652
|
+
|
|
522
653
|
def calculate_height(self, image_width: int, font_size: int) -> int:
|
|
523
654
|
if self.image is None:
|
|
524
655
|
return font_size + 20 # 图片加载失败的默认高度
|
|
525
|
-
|
|
656
|
+
|
|
526
657
|
# 计算调整大小后的图片高度
|
|
527
658
|
max_width = image_width * 0.8
|
|
528
659
|
if self.image.width > max_width:
|
|
@@ -530,52 +661,60 @@ class ImageElement(MarkdownElement):
|
|
|
530
661
|
height = int(self.image.height * ratio)
|
|
531
662
|
else:
|
|
532
663
|
height = self.image.height
|
|
533
|
-
|
|
664
|
+
|
|
534
665
|
return height + 30 # 包含上下间距
|
|
535
|
-
|
|
536
|
-
def render(
|
|
666
|
+
|
|
667
|
+
def render(
|
|
668
|
+
self,
|
|
669
|
+
image: Image.Image,
|
|
670
|
+
draw: ImageDraw.Draw,
|
|
671
|
+
x: int,
|
|
672
|
+
y: int,
|
|
673
|
+
image_width: int,
|
|
674
|
+
font_size: int,
|
|
675
|
+
) -> int:
|
|
537
676
|
if self.image is None:
|
|
538
677
|
# 图片加载失败
|
|
539
678
|
font = FontManager.get_font(font_size)
|
|
540
679
|
draw.text((x, y + 10), "[图片加载失败]", font=font, fill=(255, 0, 0))
|
|
541
680
|
return y + font_size + 20
|
|
542
|
-
|
|
681
|
+
|
|
543
682
|
# 调整图片大小
|
|
544
683
|
max_width = image_width * 0.8
|
|
545
684
|
pasted_image = self.image
|
|
546
|
-
|
|
685
|
+
|
|
547
686
|
if pasted_image.width > max_width:
|
|
548
687
|
ratio = max_width / pasted_image.width
|
|
549
688
|
new_size = (int(max_width), int(pasted_image.height * ratio))
|
|
550
689
|
pasted_image = pasted_image.resize(new_size, Image.LANCZOS)
|
|
551
|
-
|
|
690
|
+
|
|
552
691
|
# 计算居中位置
|
|
553
692
|
paste_x = x + (image_width - pasted_image.width) // 2 - 10
|
|
554
|
-
|
|
693
|
+
|
|
555
694
|
# 粘贴图片
|
|
556
|
-
if pasted_image.mode ==
|
|
695
|
+
if pasted_image.mode == "RGBA":
|
|
557
696
|
# 处理透明图片
|
|
558
697
|
image.paste(pasted_image, (paste_x, y + 15), pasted_image)
|
|
559
698
|
else:
|
|
560
699
|
image.paste(pasted_image, (paste_x, y + 15))
|
|
561
|
-
|
|
700
|
+
|
|
562
701
|
return y + pasted_image.height + 30
|
|
563
702
|
|
|
564
703
|
|
|
565
704
|
class MarkdownParser:
|
|
566
705
|
"""Markdown解析器,将文本解析为元素"""
|
|
567
|
-
|
|
706
|
+
|
|
568
707
|
@staticmethod
|
|
569
708
|
async def parse(text: str) -> List[MarkdownElement]:
|
|
570
709
|
elements = []
|
|
571
|
-
lines = text.split(
|
|
572
|
-
|
|
710
|
+
lines = text.split("\n")
|
|
711
|
+
|
|
573
712
|
i = 0
|
|
574
713
|
while i < len(lines):
|
|
575
714
|
line = lines[i].rstrip()
|
|
576
|
-
|
|
715
|
+
|
|
577
716
|
# 图片检测
|
|
578
|
-
image_match = re.search(r
|
|
717
|
+
image_match = re.search(r"!\s*\[(.*?)\]\s*\((.*?)\)", line)
|
|
579
718
|
if image_match:
|
|
580
719
|
image_url = image_match.group(2)
|
|
581
720
|
element = ImageElement(line, image_url)
|
|
@@ -583,101 +722,108 @@ class MarkdownParser:
|
|
|
583
722
|
elements.append(element)
|
|
584
723
|
i += 1
|
|
585
724
|
continue
|
|
586
|
-
|
|
725
|
+
|
|
587
726
|
# 标题
|
|
588
|
-
if line.startswith(
|
|
727
|
+
if line.startswith("#"):
|
|
589
728
|
elements.append(HeaderElement(line))
|
|
590
729
|
i += 1
|
|
591
730
|
continue
|
|
592
|
-
|
|
731
|
+
|
|
593
732
|
# 引用
|
|
594
|
-
if line.startswith(
|
|
733
|
+
if line.startswith(">"):
|
|
595
734
|
elements.append(QuoteElement(line))
|
|
596
735
|
i += 1
|
|
597
736
|
continue
|
|
598
|
-
|
|
737
|
+
|
|
599
738
|
# 列表项
|
|
600
|
-
if line.startswith(
|
|
739
|
+
if line.startswith("-") or line.startswith("*"):
|
|
601
740
|
elements.append(ListItemElement(line[1:].strip()))
|
|
602
741
|
i += 1
|
|
603
742
|
continue
|
|
604
|
-
|
|
743
|
+
|
|
605
744
|
# 代码块
|
|
606
|
-
if line.startswith(
|
|
745
|
+
if line.startswith("```"):
|
|
607
746
|
code_lines = []
|
|
608
747
|
i += 1 # 跳过开始标记行
|
|
609
|
-
|
|
610
|
-
while i < len(lines) and not lines[i].startswith(
|
|
748
|
+
|
|
749
|
+
while i < len(lines) and not lines[i].startswith("```"):
|
|
611
750
|
code_lines.append(lines[i])
|
|
612
751
|
i += 1
|
|
613
|
-
|
|
752
|
+
|
|
614
753
|
i += 1 # 跳过结束标记行
|
|
615
754
|
elements.append(CodeBlockElement(code_lines))
|
|
616
755
|
continue
|
|
617
|
-
|
|
756
|
+
|
|
618
757
|
# 检查行内样式(粗体、斜体、下划线、删除线、行内代码)
|
|
619
|
-
if re.search(
|
|
758
|
+
if re.search(
|
|
759
|
+
r"(\*\*.*?\*\*)|(\*.*?\*)|(__.*?__)|(_.*?_)|(~~.*?~~)|(`.*?`)", line
|
|
760
|
+
):
|
|
620
761
|
# 分析行内样式:
|
|
621
762
|
# - 粗体: **text** 或 __text__
|
|
622
763
|
# - 斜体: *text* 或 _text_
|
|
623
764
|
# - 删除线: ~~text~~
|
|
624
765
|
# - 行内代码: `text`
|
|
625
|
-
|
|
766
|
+
|
|
626
767
|
# 定义正则模式和对应的元素类型
|
|
627
768
|
patterns = [
|
|
628
|
-
(r
|
|
629
|
-
(r
|
|
630
|
-
(
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
(r
|
|
769
|
+
(r"\*\*(.*?)\*\*", BoldTextElement), # **粗体**
|
|
770
|
+
(r"__(.*?)__", BoldTextElement), # __粗体__
|
|
771
|
+
(
|
|
772
|
+
r"\*((?!\*\*).*?)\*",
|
|
773
|
+
ItalicTextElement,
|
|
774
|
+
), # *斜体* (但不匹配 ** 开头)
|
|
775
|
+
(r"_((?!__).*?)_", ItalicTextElement), # _斜体_ (但不匹配 __ 开头)
|
|
776
|
+
(r"~~(.*?)~~", StrikethroughTextElement), # ~~删除线~~
|
|
777
|
+
(r"__(.*?)__", UnderlineTextElement), # __下划线__
|
|
778
|
+
(r"`(.*?)`", InlineCodeElement), # `行内代码`
|
|
635
779
|
]
|
|
636
|
-
|
|
780
|
+
|
|
637
781
|
# 创建标记位置列表
|
|
638
782
|
markers = []
|
|
639
783
|
for pattern, element_class in patterns:
|
|
640
784
|
for match in re.finditer(pattern, line):
|
|
641
|
-
markers.append(
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
785
|
+
markers.append(
|
|
786
|
+
{
|
|
787
|
+
"start": match.start(),
|
|
788
|
+
"end": match.end(),
|
|
789
|
+
"text": match.group(1), # 提取内容部分
|
|
790
|
+
"element_class": element_class,
|
|
791
|
+
}
|
|
792
|
+
)
|
|
793
|
+
|
|
648
794
|
# 按开始位置排序
|
|
649
|
-
markers.sort(key=lambda x: x[
|
|
650
|
-
|
|
795
|
+
markers.sort(key=lambda x: x["start"])
|
|
796
|
+
|
|
651
797
|
# 如果没有找到任何匹配,直接添加为普通文本
|
|
652
798
|
if not markers:
|
|
653
799
|
elements.append(TextElement(line))
|
|
654
800
|
i += 1
|
|
655
801
|
continue
|
|
656
|
-
|
|
802
|
+
|
|
657
803
|
# 处理每个文本片段
|
|
658
804
|
current_pos = 0
|
|
659
805
|
for marker in markers:
|
|
660
806
|
# 添加前面的普通文本
|
|
661
|
-
if marker[
|
|
662
|
-
normal_text = line[current_pos:marker[
|
|
807
|
+
if marker["start"] > current_pos:
|
|
808
|
+
normal_text = line[current_pos : marker["start"]]
|
|
663
809
|
if normal_text:
|
|
664
810
|
elements.append(TextElement(normal_text))
|
|
665
|
-
|
|
811
|
+
|
|
666
812
|
# 添加特殊样式的文本
|
|
667
|
-
elements.append(marker[
|
|
668
|
-
current_pos = marker[
|
|
669
|
-
|
|
813
|
+
elements.append(marker["element_class"](marker["text"]))
|
|
814
|
+
current_pos = marker["end"]
|
|
815
|
+
|
|
670
816
|
# 添加最后一段普通文本
|
|
671
817
|
if current_pos < len(line):
|
|
672
818
|
elements.append(TextElement(line[current_pos:]))
|
|
673
|
-
|
|
819
|
+
|
|
674
820
|
i += 1
|
|
675
821
|
continue
|
|
676
|
-
|
|
822
|
+
|
|
677
823
|
# 行内代码 (如果之前没匹配到混合样式)
|
|
678
|
-
inline_code_matches = re.findall(r
|
|
824
|
+
inline_code_matches = re.findall(r"`([^`]+)`", line)
|
|
679
825
|
if inline_code_matches:
|
|
680
|
-
parts = re.split(r
|
|
826
|
+
parts = re.split(r"`([^`]+)`", line)
|
|
681
827
|
for j, part in enumerate(parts):
|
|
682
828
|
if j % 2 == 0: # 普通文本
|
|
683
829
|
if part:
|
|
@@ -686,88 +832,90 @@ class MarkdownParser:
|
|
|
686
832
|
elements.append(InlineCodeElement(part))
|
|
687
833
|
i += 1
|
|
688
834
|
continue
|
|
689
|
-
|
|
835
|
+
|
|
690
836
|
# 普通文本
|
|
691
837
|
elements.append(TextElement(line))
|
|
692
838
|
i += 1
|
|
693
|
-
|
|
839
|
+
|
|
694
840
|
return elements
|
|
695
841
|
|
|
696
842
|
|
|
697
843
|
class MarkdownRenderer:
|
|
698
844
|
"""Markdown渲染器,将元素渲染为图像"""
|
|
699
|
-
|
|
700
|
-
def __init__(
|
|
845
|
+
|
|
846
|
+
def __init__(
|
|
847
|
+
self,
|
|
848
|
+
font_size: int = 26,
|
|
849
|
+
width: int = 800,
|
|
850
|
+
bg_color: Tuple[int, int, int] = (255, 255, 255),
|
|
851
|
+
):
|
|
701
852
|
self.font_size = font_size
|
|
702
853
|
self.width = width
|
|
703
854
|
self.bg_color = bg_color
|
|
704
|
-
|
|
855
|
+
|
|
705
856
|
async def render(self, markdown_text: str) -> Image.Image:
|
|
706
857
|
# 解析Markdown文本
|
|
707
858
|
elements = await MarkdownParser.parse(markdown_text)
|
|
708
|
-
|
|
859
|
+
|
|
709
860
|
# 计算总高度
|
|
710
861
|
total_height = 20 # 初始边距
|
|
711
862
|
for element in elements:
|
|
712
863
|
total_height += element.calculate_height(self.width, self.font_size)
|
|
713
|
-
|
|
864
|
+
|
|
714
865
|
# 为页脚添加额外空间
|
|
715
866
|
footer_height = 40
|
|
716
867
|
total_height += 20 + footer_height # 结束边距 + 页脚高度
|
|
717
|
-
|
|
868
|
+
|
|
718
869
|
# 创建图像
|
|
719
|
-
image = Image.new(
|
|
870
|
+
image = Image.new("RGB", (self.width, max(100, total_height)), self.bg_color)
|
|
720
871
|
draw = ImageDraw.Draw(image)
|
|
721
|
-
|
|
872
|
+
|
|
722
873
|
# 渲染元素
|
|
723
874
|
y = 10
|
|
724
875
|
for element in elements:
|
|
725
876
|
y = element.render(image, draw, 10, y, self.width, self.font_size)
|
|
726
|
-
|
|
877
|
+
|
|
727
878
|
# 添加页脚
|
|
728
879
|
# 克莱因蓝色,近似RGB为(0, 47, 167)
|
|
729
880
|
klein_blue = (0, 47, 167)
|
|
730
881
|
# 灰色
|
|
731
882
|
grey_color = (130, 130, 130)
|
|
732
|
-
|
|
883
|
+
|
|
733
884
|
# 绘制"Powered by AstrBot"文本
|
|
734
885
|
footer_font_size = 20
|
|
735
886
|
footer_font = FontManager.get_font(footer_font_size)
|
|
736
|
-
|
|
887
|
+
|
|
737
888
|
# 获取"Powered by "和"AstrBot"的宽度以便居中
|
|
738
889
|
powered_by_text = "Powered by "
|
|
739
890
|
astrbot_text = f"AstrBot v{VERSION}"
|
|
740
|
-
|
|
891
|
+
|
|
741
892
|
powered_by_width, _ = TextMeasurer.get_text_size(powered_by_text, footer_font)
|
|
742
893
|
astrbot_width, _ = TextMeasurer.get_text_size(astrbot_text, footer_font)
|
|
743
|
-
|
|
894
|
+
|
|
744
895
|
total_width = powered_by_width + astrbot_width
|
|
745
896
|
x_start = (self.width - total_width) // 2
|
|
746
|
-
|
|
897
|
+
|
|
747
898
|
footer_y = total_height - footer_height
|
|
748
|
-
|
|
899
|
+
|
|
749
900
|
# 绘制"Powered by "(灰色)
|
|
750
901
|
draw.text(
|
|
751
|
-
(x_start, footer_y),
|
|
752
|
-
powered_by_text,
|
|
753
|
-
font=footer_font,
|
|
754
|
-
fill=grey_color
|
|
902
|
+
(x_start, footer_y), powered_by_text, font=footer_font, fill=grey_color
|
|
755
903
|
)
|
|
756
|
-
|
|
904
|
+
|
|
757
905
|
# 绘制"AstrBot"(克莱因蓝)
|
|
758
906
|
draw.text(
|
|
759
|
-
(x_start + powered_by_width, footer_y),
|
|
760
|
-
astrbot_text,
|
|
761
|
-
font=footer_font,
|
|
762
|
-
fill=klein_blue
|
|
907
|
+
(x_start + powered_by_width, footer_y),
|
|
908
|
+
astrbot_text,
|
|
909
|
+
font=footer_font,
|
|
910
|
+
fill=klein_blue,
|
|
763
911
|
)
|
|
764
|
-
|
|
912
|
+
|
|
765
913
|
return image
|
|
766
914
|
|
|
767
915
|
|
|
768
916
|
class LocalRenderStrategy(RenderStrategy):
|
|
769
917
|
"""本地渲染策略实现"""
|
|
770
|
-
|
|
918
|
+
|
|
771
919
|
async def render_custom_template(
|
|
772
920
|
self, tmpl_str: str, tmpl_data: dict, return_url: bool = True
|
|
773
921
|
) -> str:
|
|
@@ -776,9 +924,9 @@ class LocalRenderStrategy(RenderStrategy):
|
|
|
776
924
|
async def render(self, text: str, return_url: bool = False) -> str:
|
|
777
925
|
# 创建渲染器
|
|
778
926
|
renderer = MarkdownRenderer(font_size=26, width=800)
|
|
779
|
-
|
|
927
|
+
|
|
780
928
|
# 渲染Markdown文本
|
|
781
929
|
image = await renderer.render(text)
|
|
782
|
-
|
|
930
|
+
|
|
783
931
|
# 保存图像并返回路径/URL
|
|
784
932
|
return save_temp_img(image)
|