""" 微信 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) # 后处理:为段落之间添加额外的空行 # 在段落之间插入空行(使用

 


) # 将

替换为

 

来添加空行 html = re.sub(r'

\s*

', '

 

', 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'

([^<]*)
' def replace_code_block(match): lang = match.group(1) or 'text' code = match.group(2) # 解码 HTML 实体 code = code.replace('<', '<').replace('>', '>').replace('&', '&') 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'
{code}
' return f'
{highlighted}
' 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""" """ full_html = f""" 微信公众号文章 {base_css}
{html_body}
""" # 使用 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)