Files
yidaima_tools/markdown_editor.py

816 lines
26 KiB
Python
Raw Normal View History

2026-04-09 14:55:54 +08:00
"""
微信 Markdown 编辑器模块
支持主题字体代码块样式自定义CSS等功能
"""
from __future__ import annotations
import json
import os
import sys
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass, asdict
from pathlib import Path
import markdown
from markdown.extensions import fenced_code, tables, nl2br
from pygments import highlight
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.formatters import HtmlFormatter
from pygments.styles import get_all_styles
from premailer import Premailer
@dataclass
class ThemeConfig:
"""主题配置"""
id: str
name: str
primary_color: str = "#07C160"
background: str = "#ffffff"
text_color: str = "#333333"
link_color: str = "#576b95"
code_bg: str = "#f6f8fa"
blockquote_bg: str = "#f9f9f9"
blockquote_border: str = "#dfe2e5"
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "ThemeConfig":
return cls(**data)
@dataclass
class FontConfig:
"""字体配置"""
body_font: str = "-apple-system-font, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', Arial, sans-serif"
heading_font: str = ""
code_font: str = "Consolas, Monaco, 'Courier New', monospace"
body_size: int = 16
heading_scale: float = 1.3
line_height: float = 1.8
def __post_init__(self):
if not self.heading_font:
self.heading_font = self.body_font
@dataclass
class CodeBlockConfig:
"""代码块配置"""
theme: str = "default"
line_numbers: bool = False
background: str = "#f6f8fa"
border_radius: int = 6
# 常用代码高亮主题列表
COMMON_THEMES = [
"default", "emacs", "friendly", "colorful", "autumn", "murphy", "manni",
"monokai", "perldoc", "pastie", "borland", "trac", "native", "fruity",
"bw", "vim", "vs", "tango", "rrt", "xcode", "igor", "paraiso-light",
"paraiso-dark", "lovelace", "algol", "algol_nu", "arduino", "rainbow_dash",
"abap", "solarized-dark", "solarized-light", "sas", "stata", "stata-light",
"stata-dark", "inkpot", "zenburn", "gruvbox-dark", "gruvbox-light",
"dracula", "one-dark", "material", "nord", "github-dark"
]
@property
def available_themes(self) -> List[str]:
"""获取所有可用的代码高亮主题"""
try:
all_themes = list(get_all_styles())
return all_themes if all_themes else self.COMMON_THEMES
except Exception:
return self.COMMON_THEMES
@dataclass
class CssScheme:
"""CSS 样式方案"""
id: str
name: str
css: str
font_config: dict = None
code_block_config: dict = None
theme_id: str = "default" # 主题 ID
primary_color: str = "#07C160" # 主题色
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"css": self.css,
"font_config": self.font_config or {},
"code_block_config": self.code_block_config or {},
"theme_id": self.theme_id,
"primary_color": self.primary_color
}
@classmethod
def from_dict(cls, data: dict) -> "CssScheme":
return cls(
id=data.get("id", ""),
name=data.get("name", ""),
css=data.get("css", ""),
font_config=data.get("font_config", {}),
code_block_config=data.get("code_block_config", {}),
theme_id=data.get("theme_id", "default"),
primary_color=data.get("primary_color", "#07C160")
)
@dataclass
class EditorConfig:
"""编辑器完整配置"""
version: str = "1.0.0"
theme: str = "default"
custom_themes: List[dict] = None
font: FontConfig = None
code_block: CodeBlockConfig = None
custom_css: str = ""
css_schemes: List[dict] = None # CSS 样式方案列表
def __post_init__(self):
if self.custom_themes is None:
self.custom_themes = []
if self.font is None:
self.font = FontConfig()
if self.code_block is None:
self.code_block = CodeBlockConfig()
if self.css_schemes is None:
self.css_schemes = []
class ThemeManager:
"""主题管理器"""
# 预设主题
PRESET_THEMES = {
"default": ThemeConfig(
id="default",
name="默认主题",
primary_color="#07C160",
background="#ffffff",
text_color="#333333"
),
"elegant": ThemeConfig(
id="elegant",
name="优雅主题",
primary_color="#2c3e50",
background="#fafafa",
text_color="#2c3e50"
),
"tech": ThemeConfig(
id="tech",
name="科技主题",
primary_color="#3498db",
background="#1e1e1e",
text_color="#d4d4d4",
code_bg="#2d2d2d"
),
"literary": ThemeConfig(
id="literary",
name="文艺主题",
primary_color="#8b4513",
background="#fefcf8",
text_color="#4a4a4a"
),
"festive": ThemeConfig(
id="festive",
name="节日主题",
primary_color="#e74c3c",
background="#fff5f5",
text_color="#c0392b"
)
}
def __init__(self, config_dir: str = None):
if config_dir:
self.config_dir = config_dir
else:
# 获取程序运行目录(兼容 exe 和普通 Python 运行)
if getattr(sys, 'frozen', False):
# 如果是打包后的 exe使用 exe 所在目录
app_dir = os.path.dirname(sys.executable)
else:
# 如果是普通 Python 运行,使用脚本所在目录
app_dir = os.path.dirname(os.path.abspath(__file__))
self.config_dir = os.path.join(app_dir, "data", "editor")
os.makedirs(self.config_dir, exist_ok=True)
self.config_file = os.path.join(self.config_dir, "editor_config.json")
print(f"[ThemeManager] 配置目录: {self.config_dir}")
print(f"[ThemeManager] 配置文件: {self.config_file}")
self.config = self._load_config()
self._custom_themes: Dict[str, ThemeConfig] = {}
self._css_schemes: Dict[str, CssScheme] = {}
self._load_custom_themes()
self._load_css_schemes()
def _load_config(self) -> EditorConfig:
"""加载配置"""
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return self._parse_config(data)
except Exception as e:
print(f"[ThemeManager] 加载配置失败: {e}")
return EditorConfig()
def _parse_config(self, data: dict) -> EditorConfig:
"""解析配置数据"""
config = EditorConfig(
version=data.get("version", "1.0.0"),
theme=data.get("theme", "default"),
custom_themes=data.get("custom_themes", []),
custom_css=data.get("custom_css", "")
)
# 解析字体配置
if "font" in data:
font_data = data["font"]
config.font = FontConfig(
body_font=font_data.get("body_font", config.font.body_font),
heading_font=font_data.get("heading_font", config.font.heading_font),
code_font=font_data.get("code_font", config.font.code_font),
body_size=font_data.get("body_size", config.font.body_size),
heading_scale=font_data.get("heading_scale", config.font.heading_scale),
line_height=font_data.get("line_height", config.font.line_height)
)
# 解析代码块配置
if "code_block" in data:
code_data = data["code_block"]
config.code_block = CodeBlockConfig(
theme=code_data.get("theme", config.code_block.theme),
line_numbers=code_data.get("line_numbers", config.code_block.line_numbers),
background=code_data.get("background", config.code_block.background),
border_radius=code_data.get("border_radius", config.code_block.border_radius)
)
# 解析 CSS 方案
if "css_schemes" in data:
config.css_schemes = data["css_schemes"]
return config
def _load_custom_themes(self):
"""加载自定义主题"""
for theme_data in self.config.custom_themes:
try:
theme = ThemeConfig.from_dict(theme_data)
self._custom_themes[theme.id] = theme
except Exception as e:
print(f"[ThemeManager] 加载自定义主题失败: {e}")
def _load_css_schemes(self):
"""加载 CSS 样式方案"""
for scheme_data in self.config.css_schemes:
try:
scheme = CssScheme.from_dict(scheme_data)
self._css_schemes[scheme.id] = scheme
except Exception as e:
print(f"[ThemeManager] 加载 CSS 方案失败: {e}")
def save_config(self):
"""保存配置"""
data = {
"version": self.config.version,
"theme": self.config.theme,
"custom_themes": [t.to_dict() for t in self._custom_themes.values()],
"font": {
"body_font": self.config.font.body_font,
"heading_font": self.config.font.heading_font,
"code_font": self.config.font.code_font,
"body_size": self.config.font.body_size,
"heading_scale": self.config.font.heading_scale,
"line_height": self.config.font.line_height
},
"code_block": {
"theme": self.config.code_block.theme,
"line_numbers": self.config.code_block.line_numbers,
"background": self.config.code_block.background,
"border_radius": self.config.code_block.border_radius
},
"custom_css": self.config.custom_css,
"css_schemes": [s.to_dict() for s in self._css_schemes.values()]
}
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_all_themes(self) -> List[ThemeConfig]:
"""获取所有主题(预设+自定义)"""
themes = list(self.PRESET_THEMES.values())
themes.extend(self._custom_themes.values())
return themes
def get_theme(self, theme_id: str) -> Optional[ThemeConfig]:
"""获取指定主题"""
if theme_id in self.PRESET_THEMES:
return self.PRESET_THEMES[theme_id]
return self._custom_themes.get(theme_id)
def get_current_theme(self) -> ThemeConfig:
"""获取当前主题"""
return self.get_theme(self.config.theme) or self.PRESET_THEMES["default"]
def set_theme(self, theme_id: str):
"""设置当前主题"""
if theme_id in self.PRESET_THEMES or theme_id in self._custom_themes:
self.config.theme = theme_id
self.save_config()
def add_custom_theme(self, theme: ThemeConfig):
"""添加自定义主题"""
self._custom_themes[theme.id] = theme
self.save_config()
def remove_custom_theme(self, theme_id: str):
"""删除自定义主题"""
if theme_id in self._custom_themes:
del self._custom_themes[theme_id]
if self.config.theme == theme_id:
self.config.theme = "default"
self.save_config()
# ========== CSS 样式方案管理 ==========
def get_all_css_schemes(self) -> List[CssScheme]:
"""获取所有 CSS 样式方案"""
return list(self._css_schemes.values())
def get_css_scheme(self, scheme_id: str) -> Optional[CssScheme]:
"""获取指定 CSS 方案"""
return self._css_schemes.get(scheme_id)
def add_css_scheme(self, scheme: CssScheme):
"""添加 CSS 样式方案"""
self._css_schemes[scheme.id] = scheme
self.save_config()
def remove_css_scheme(self, scheme_id: str):
"""删除 CSS 样式方案"""
if scheme_id in self._css_schemes:
del self._css_schemes[scheme_id]
self.save_config()
def apply_css_scheme(self, scheme_id: str) -> bool:
"""应用 CSS 样式方案"""
scheme = self._css_schemes.get(scheme_id)
if not scheme:
return False
# 应用主题
if scheme.theme_id:
self.config.theme = scheme.theme_id
# 应用主题色(更新当前主题的主色调)
if scheme.primary_color and scheme.theme_id:
# 先从预设主题中查找,再从自定义主题中查找
theme = self.PRESET_THEMES.get(scheme.theme_id)
if not theme:
theme = self._custom_themes.get(scheme.theme_id)
if theme:
theme.primary_color = scheme.primary_color
# 应用 CSS
self.config.custom_css = scheme.css
# 应用字体配置
if scheme.font_config:
for key, value in scheme.font_config.items():
if hasattr(self.config.font, key):
setattr(self.config.font, key, value)
# 应用代码块配置
if scheme.code_block_config:
for key, value in scheme.code_block_config.items():
if hasattr(self.config.code_block, key):
setattr(self.config.code_block, key, value)
self.save_config()
return True
class MarkdownToWechatConverter:
"""Markdown 转微信公众号 HTML 转换器"""
def __init__(self, theme_manager: ThemeManager = None):
self.theme_manager = theme_manager or ThemeManager()
def convert(self, md_content: str) -> str:
"""转换 Markdown 为微信公众号 HTML"""
config = self.theme_manager.config
theme = self.theme_manager.get_current_theme()
# 1. 转换 Markdown 为 HTML
html_body = self._markdown_to_html(md_content, config.code_block.theme)
# 2. 生成完整 HTML
html = self._generate_full_html(html_body, theme, config)
return html
def _markdown_to_html(self, md_content: str, code_theme: str) -> str:
"""Markdown 转 HTML"""
# 配置 Markdown 扩展
extensions = [
'markdown.extensions.fenced_code',
'markdown.extensions.tables',
'markdown.extensions.nl2br',
]
# 预处理确保空行正确Markdown 需要空行来分隔段落)
# 将多个连续换行符转换为两个换行符(段落分隔)
import re
md_content = re.sub(r'\n{3,}', '\n\n', md_content)
# 使用 Pygments 进行代码高亮
md = markdown.Markdown(extensions=extensions)
html = md.convert(md_content)
# 处理代码块高亮
html = self._highlight_code_blocks(html, code_theme)
# 后处理:为段落之间添加额外的空行
# 在段落之间插入空行(使用 <p>&nbsp;</p> 或 <br>
# 将 </p><p> 替换为 </p><p>&nbsp;</p><p> 来添加空行
html = re.sub(r'</p>\s*<p>', '</p><p>&nbsp;</p><p>', html)
return html
def _highlight_code_blocks(self, html: str, theme: str) -> str:
"""高亮代码块"""
import re
from pygments.styles import get_style_by_name
# 验证主题是否可用,如果不可用则使用默认主题
try:
get_style_by_name(theme)
except:
theme = "default"
# 查找代码块
pattern = r'<pre><code(?:\s+class="language-([^"]*)")?>([^<]*)</code></pre>'
def replace_code_block(match):
lang = match.group(1) or 'text'
code = match.group(2)
# 解码 HTML 实体
code = code.replace('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
try:
lexer = get_lexer_by_name(lang)
except:
lexer = guess_lexer(code)
try:
formatter = HtmlFormatter(
style=theme,
noclasses=True,
cssclass='highlight'
)
highlighted = highlight(code, lexer, formatter)
except Exception:
# 如果高亮失败,返回原始代码
highlighted = f'<pre><code>{code}</code></pre>'
return f'<div class="code-block-wrapper">{highlighted}</div>'
return re.sub(pattern, replace_code_block, html, flags=re.DOTALL)
def _generate_full_html(self, html_body: str, theme: ThemeConfig, config: EditorConfig) -> str:
"""生成完整 HTML 文档"""
# 基础样式
base_css = f"""
<style>
.wechat-article {{
max-width: 677px;
margin: 0 auto;
padding: 20px;
font-family: {config.font.body_font};
font-size: {config.font.body_size}px;
line-height: {config.font.line_height};
color: {theme.text_color};
background: {theme.background};
}}
.wechat-article h1 {{
font-size: {config.font.body_size * config.font.heading_scale * 1.5:.0f}px;
font-weight: bold;
margin: 1.5em 0 0.8em;
padding-bottom: 0.3em;
border-bottom: 2px solid {theme.primary_color};
color: {theme.primary_color};
}}
.wechat-article h2 {{
font-size: {config.font.body_size * config.font.heading_scale * 1.3:.0f}px;
font-weight: bold;
margin: 1.3em 0 0.6em;
padding-left: 12px;
border-left: 4px solid {theme.primary_color};
}}
.wechat-article h3 {{
font-size: {config.font.body_size * config.font.heading_scale * 1.1:.0f}px;
font-weight: bold;
margin: 1em 0 0.5em;
color: {theme.primary_color};
}}
.wechat-article p {{
margin: 1em 0;
text-align: justify;
}}
.wechat-article a {{
color: {theme.link_color};
text-decoration: none;
}}
.wechat-article img {{
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}}
.wechat-article blockquote {{
margin: 1em 0;
padding: 12px 16px;
background: {theme.blockquote_bg};
border-left: 4px solid {theme.blockquote_border};
color: #6a737d;
}}
.wechat-article ul, .wechat-article ol {{
margin: 1em 0;
padding-left: 2em;
}}
.wechat-article li {{
margin: 0.3em 0;
}}
.wechat-article code {{
font-family: {config.font.code_font};
background: {theme.code_bg};
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}}
.wechat-article pre {{
font-family: {config.font.code_font};
background: {config.code_block.background};
padding: 16px;
border-radius: {config.code_block.border_radius}px;
overflow-x: auto;
margin: 1em 0;
}}
.wechat-article pre code {{
background: transparent;
padding: 0;
}}
.wechat-article table {{
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}}
.wechat-article th, .wechat-article td {{
border: 1px solid #dfe2e5;
padding: 8px 12px;
text-align: left;
}}
.wechat-article th {{
background: #f6f8fa;
font-weight: bold;
}}
.wechat-article tr:nth-child(even) {{
background: #fafafa;
}}
{config.custom_css}
</style>
"""
full_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信公众号文章</title>
{base_css}
</head>
<body>
<div class="wechat-article">
{html_body}
</div>
</body>
</html>"""
# 使用 premailer 将 CSS 内联到 HTML 元素中(微信公众号需要内联样式)
try:
premailer = Premailer(full_html, remove_classes=False)
inlined_html = premailer.transform()
return inlined_html
except Exception as e:
print(f"[MarkdownToWechatConverter] CSS 内联失败: {e}")
return full_html
def export_css(self) -> str:
"""导出当前主题 CSS"""
theme = self.theme_manager.get_current_theme()
config = self.theme_manager.config
css = f"""/* 微信公众号文章样式 - {theme.name} */
.wechat-article {{
max-width: 677px;
margin: 0 auto;
padding: 20px;
font-family: {config.font.body_font};
font-size: {config.font.body_size}px;
line-height: {config.font.line_height};
color: {theme.text_color};
background: {theme.background};
}}
.wechat-article h1 {{
font-size: {config.font.body_size * config.font.heading_scale * 1.5:.0f}px;
font-weight: bold;
margin: 1.5em 0 0.8em;
padding-bottom: 0.3em;
border-bottom: 2px solid {theme.primary_color};
color: {theme.primary_color};
}}
.wechat-article h2 {{
font-size: {config.font.body_size * config.font.heading_scale * 1.3:.0f}px;
font-weight: bold;
margin: 1.3em 0 0.6em;
padding-left: 12px;
border-left: 4px solid {theme.primary_color};
}}
.wechat-article h3 {{
font-size: {config.font.body_size * config.font.heading_scale * 1.1:.0f}px;
font-weight: bold;
margin: 1em 0 0.5em;
color: {theme.primary_color};
}}
.wechat-article p {{
margin: 1em 0;
text-align: justify;
}}
.wechat-article a {{
color: {theme.link_color};
text-decoration: none;
}}
.wechat-article img {{
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
}}
.wechat-article blockquote {{
margin: 1em 0;
padding: 12px 16px;
background: {theme.blockquote_bg};
border-left: 4px solid {theme.blockquote_border};
color: #6a737d;
}}
.wechat-article ul, .wechat-article ol {{
margin: 1em 0;
padding-left: 2em;
}}
.wechat-article li {{
margin: 0.3em 0;
}}
.wechat-article code {{
font-family: {config.font.code_font};
background: {theme.code_bg};
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}}
.wechat-article pre {{
font-family: {config.font.code_font};
background: {config.code_block.background};
padding: 16px;
border-radius: {config.code_block.border_radius}px;
overflow-x: auto;
margin: 1em 0;
}}
.wechat-article pre code {{
background: transparent;
padding: 0;
}}
.wechat-article table {{
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}}
.wechat-article th, .wechat-article td {{
border: 1px solid #dfe2e5;
padding: 8px 12px;
text-align: left;
}}
.wechat-article th {{
background: #f6f8fa;
font-weight: bold;
}}
.wechat-article tr:nth-child(even) {{
background: #fafafa;
}}
"""
return css
class WechatMarkdownEditor:
"""微信 Markdown 编辑器主类"""
def __init__(self):
self.theme_manager = ThemeManager()
self.converter = MarkdownToWechatConverter(self.theme_manager)
def render(self, md_content: str) -> str:
"""渲染 Markdown 为 HTML"""
return self.converter.convert(md_content)
def get_config(self) -> EditorConfig:
"""获取编辑器配置"""
return self.theme_manager.config
def save_config(self):
"""保存配置"""
self.theme_manager.save_config()
def update_font_config(self, **kwargs):
"""更新字体配置"""
for key, value in kwargs.items():
if hasattr(self.theme_manager.config.font, key):
setattr(self.theme_manager.config.font, key, value)
self.save_config()
def update_code_block_config(self, **kwargs):
"""更新代码块配置"""
for key, value in kwargs.items():
if hasattr(self.theme_manager.config.code_block, key):
setattr(self.theme_manager.config.code_block, key, value)
self.save_config()
def update_custom_css(self, css: str):
"""更新自定义 CSS"""
self.theme_manager.config.custom_css = css
self.save_config()
def export_html(self, md_content: str, output_path: str):
"""导出 HTML 文件"""
html = self.render(md_content)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html)
def export_css(self, output_path: str):
"""导出 CSS 文件"""
css = self.converter.export_css()
with open(output_path, 'w', encoding='utf-8') as f:
f.write(css)
# 便捷函数
def create_editor() -> WechatMarkdownEditor:
"""创建编辑器实例"""
return WechatMarkdownEditor()
def markdown_to_wechat(md_content: str, theme_id: str = "default") -> str:
"""快速转换 Markdown 为微信公众号 HTML"""
editor = create_editor()
editor.theme_manager.set_theme(theme_id)
return editor.render(md_content)