md2wx-cli 0.2.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.
md2wx/gallery.py ADDED
@@ -0,0 +1,199 @@
1
+ """Theme Gallery generator for md2wx — Shadcn-styled interactive theme explorer."""
2
+
3
+ import json
4
+ import tempfile
5
+ import webbrowser
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .converter import WeChatConverter
10
+ from .theme import list_themes, load_theme
11
+
12
+
13
+ def generate_gallery_html(markdown_text: str, title: str = "Theme Gallery", base_dir: Optional[Path] = None) -> str:
14
+ """Compile Markdown across all themes and wrap in a clean Shadcn-styled single-file explorer."""
15
+ themes = list_themes()
16
+ theme_data = {}
17
+
18
+ for tname in themes:
19
+ try:
20
+ th = load_theme(tname)
21
+ conv = WeChatConverter(theme=th)
22
+ res = conv.convert(markdown_text, base_dir=base_dir)
23
+ theme_data[tname] = {
24
+ "name": tname,
25
+ "description": getattr(th, "description", ""),
26
+ "primary": th.colors.get("primary", "#2563eb"),
27
+ "html": res.html,
28
+ }
29
+ except Exception:
30
+ continue
31
+
32
+ themes_json = json.dumps(theme_data, ensure_ascii=False).replace("</script>", "<\\/script>")
33
+ default_theme = "default" if "default" in theme_data else list(theme_data.keys())[0]
34
+
35
+ return f"""<!DOCTYPE html>
36
+ <html lang="zh-CN" class="dark">
37
+ <head>
38
+ <meta charset="UTF-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
40
+ <title>{title} — md2wx Theme Gallery</title>
41
+ <style>
42
+ :root {{
43
+ --bg: #09090b; --card-bg: #18181b; --border: #27272a;
44
+ --text-primary: #fafafa; --text-muted: #a1a1aa; --accent: #2563eb;
45
+ }}
46
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
47
+ body {{
48
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", sans-serif;
49
+ background-color: var(--bg); color: var(--text-primary); min-height: 100vh; display: flex; flex-direction: column;
50
+ }}
51
+ header {{
52
+ position: sticky; top: 0; z-index: 50; background: rgba(9, 9, 11, 0.85); backdrop-filter: blur(12px);
53
+ border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px;
54
+ }}
55
+ .brand {{ display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; }}
56
+ .brand-badge {{ font-size: 11px; padding: 2px 7px; background: #27272a; border: 1px solid #3f3f46; border-radius: 9999px; color: #d4d4d8; }}
57
+ .controls {{ display: flex; align-items: center; gap: 10px; }}
58
+ .btn {{
59
+ display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; font-size: 13px; font-weight: 500;
60
+ border-radius: 6px; cursor: pointer; transition: all 0.15s ease; border: 1px solid var(--border);
61
+ background: var(--card-bg); color: var(--text-primary); outline: none;
62
+ }}
63
+ .btn-primary {{ background: var(--accent); border-color: var(--accent); color: white; }}
64
+ .btn-primary:hover {{ background: #1d4ed8; }}
65
+ .layout {{ display: flex; flex: 1; overflow: hidden; }}
66
+ aside {{
67
+ width: 280px; flex-shrink: 0; background: var(--card-bg); border-right: 1px solid var(--border);
68
+ padding: 16px; display: flex; flex-direction: column; gap: 6px; overflow-y: auto;
69
+ }}
70
+ .theme-item {{
71
+ display: flex; flex-direction: column; padding: 10px 12px; border-radius: 6px;
72
+ cursor: pointer; transition: all 0.15s ease; border: 1px solid transparent; text-align: left; background: transparent; color: inherit;
73
+ }}
74
+ .theme-item:hover {{ background: rgba(255, 255, 255, 0.04); }}
75
+ .theme-item.active {{ background: rgba(37, 99, 235, 0.12); border-color: rgba(37, 99, 235, 0.35); }}
76
+ .theme-title {{ font-size: 13px; font-weight: 600; margin-bottom: 2px; }}
77
+ .theme-desc {{ font-size: 11px; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }}
78
+ main {{ flex: 1; display: flex; justify-content: center; align-items: flex-start; padding: 32px 24px; overflow-y: auto; background: #0c0d12; }}
79
+ .canvas {{
80
+ width: 100%; max-width: 640px; background: #ffffff; color: #1e293b; border-radius: 12px;
81
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); padding: 36px 24px; min-height: 80vh; transition: all 0.2s ease;
82
+ }}
83
+ .toast {{
84
+ position: fixed; bottom: 24px; right: 24px; background: #10b981; color: white; padding: 10px 18px;
85
+ border-radius: 6px; font-size: 13px; font-weight: 500; opacity: 0; transform: translateY(10px);
86
+ transition: all 0.2s ease; pointer-events: none; z-index: 100;
87
+ }}
88
+ .toast.show {{ opacity: 1; transform: translateY(0); }}
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <header>
93
+ <div class="brand">
94
+ <span>md2wx</span>
95
+ <span class="brand-badge">Theme Gallery</span>
96
+ </div>
97
+ <div class="controls">
98
+ <button id="copy-btn" class="btn btn-primary" onclick="copyCurrentHtml()">📋 复制当前主题富文本</button>
99
+ </div>
100
+ </header>
101
+ <div class="layout">
102
+ <aside id="theme-list"></aside>
103
+ <main>
104
+ <div class="canvas" id="preview-canvas"></div>
105
+ </main>
106
+ </div>
107
+ <div class="toast" id="toast">已成功复制微信排版富文本!</div>
108
+ <script id="themes-data" type="application/json">
109
+ {themes_json}
110
+ </script>
111
+ <script>
112
+ const themesData = JSON.parse(document.getElementById('themes-data').textContent);
113
+ let currentTheme = '{default_theme}';
114
+
115
+ function renderSidebar() {{
116
+ const listEl = document.getElementById('theme-list');
117
+ listEl.innerHTML = '';
118
+ Object.keys(themesData).forEach(key => {{
119
+ const item = themesData[key];
120
+ const btn = document.createElement('button');
121
+ btn.className = `theme-item ${{key === currentTheme ? 'active' : ''}}`;
122
+ btn.onclick = () => selectTheme(key);
123
+ btn.innerHTML = `
124
+ <div class="theme-title" style="color: ${{key === currentTheme ? 'var(--accent)' : 'var(--text-primary)'}}">${{item.name}}</div>
125
+ <div class="theme-desc">${{item.description}}</div>
126
+ `;
127
+ listEl.appendChild(btn);
128
+ }});
129
+ }}
130
+
131
+ function selectTheme(name) {{
132
+ currentTheme = name;
133
+ renderSidebar();
134
+ const canvas = document.getElementById('preview-canvas');
135
+ canvas.innerHTML = themesData[name].html;
136
+ }}
137
+
138
+ async function copyCurrentHtml() {{
139
+ const htmlContent = themesData[currentTheme].html;
140
+ try {{
141
+ const blobHtml = new Blob([htmlContent], {{ type: 'text/html' }});
142
+ const blobText = new Blob([document.getElementById('preview-canvas').innerText], {{ type: 'text/plain' }});
143
+ await navigator.clipboard.write([
144
+ new ClipboardItem({{ 'text/html': blobHtml, 'text/plain': blobText }})
145
+ ]);
146
+ showToast('已成功复制微信排版富文本!');
147
+ }} catch (err) {{
148
+ showToast('复制失败,请手动全选预览区域复制');
149
+ }}
150
+ }}
151
+
152
+ function showToast(msg) {{
153
+ const toast = document.getElementById('toast');
154
+ toast.textContent = msg;
155
+ toast.classList.add('show');
156
+ setTimeout(() => toast.classList.remove('show'), 2000);
157
+ }}
158
+
159
+ selectTheme(currentTheme);
160
+ </script>
161
+ </body>
162
+ </html>"""
163
+
164
+
165
+ def open_in_browser(html_content: str) -> None:
166
+ """Save gallery HTML to a temp file and open in default browser."""
167
+ with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
168
+ f.write(html_content)
169
+ temp_path = f.name
170
+ webbrowser.open(f"file://{temp_path}")
171
+
172
+
173
+ def launch_gallery(
174
+ input_source: object,
175
+ title: str = "Theme Gallery",
176
+ output_html: Optional[Path] = None,
177
+ open_browser: bool = True,
178
+ ) -> Path:
179
+ """Compile gallery across themes, save to file and open in browser."""
180
+ if isinstance(input_source, Path) and input_source.exists():
181
+ markdown_text = input_source.read_text(encoding="utf-8-sig")
182
+ base_dir = input_source.parent
183
+ else:
184
+ markdown_text = str(input_source)
185
+ base_dir = None
186
+
187
+ html = generate_gallery_html(markdown_text, title=title, base_dir=base_dir)
188
+ if output_html is None:
189
+ with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
190
+ f.write(html)
191
+ target = Path(f.name)
192
+ else:
193
+ output_html.parent.mkdir(parents=True, exist_ok=True)
194
+ output_html.write_text(html, encoding="utf-8")
195
+ target = output_html
196
+
197
+ if open_browser:
198
+ webbrowser.open(f"file://{target.resolve()}")
199
+ return target
md2wx/publisher.py ADDED
@@ -0,0 +1,136 @@
1
+ """微信公众号草稿直传与发布模块 —— 原生微信 Open Platform API 接入。"""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import List, Optional, Tuple
6
+
7
+ from .wechat_api import (
8
+ _http_post_json,
9
+ get_access_token,
10
+ upload_article_image,
11
+ upload_cover_material,
12
+ )
13
+
14
+
15
+ @dataclass
16
+ class DraftResult:
17
+ media_id: str
18
+
19
+
20
+ def create_draft(
21
+ access_token: str,
22
+ title: str,
23
+ html: str,
24
+ digest: str,
25
+ thumb_media_id: str,
26
+ author: Optional[str] = None,
27
+ show_cover_pic: int = 0,
28
+ ) -> DraftResult:
29
+ """调用微信官方 API 创建图文草稿。
30
+
31
+ 接口: POST https://api.weixin.qq.com/cgi-bin/draft/add
32
+ """
33
+ if not thumb_media_id:
34
+ raise ValueError("创建微信公众号图文草稿必须提供封面素材 thumb_media_id")
35
+
36
+ article = {
37
+ "title": title,
38
+ "author": author or "",
39
+ "digest": digest,
40
+ "content": html,
41
+ "thumb_media_id": thumb_media_id,
42
+ "show_cover_pic": show_cover_pic,
43
+ "need_open_comment": 1,
44
+ "only_fans_can_comment": 0,
45
+ }
46
+
47
+ data = _http_post_json(
48
+ "https://api.weixin.qq.com/cgi-bin/draft/add",
49
+ params={"access_token": access_token},
50
+ json_data={"articles": [article]},
51
+ )
52
+
53
+ errcode = data.get("errcode", 0)
54
+ if errcode != 0:
55
+ errmsg = data.get("errmsg", "unknown error")
56
+ raise ValueError(f"创建微信草稿失败: errcode={errcode}, errmsg={errmsg}")
57
+
58
+ media_id = data.get("media_id")
59
+ if not media_id:
60
+ raise ValueError(f"创建草稿异常,未返回 media_id: {data}")
61
+
62
+ return DraftResult(media_id=media_id)
63
+
64
+
65
+ def publish_to_wechat(
66
+ html: str,
67
+ title: str,
68
+ digest: str,
69
+ images: List[str],
70
+ appid: str,
71
+ secret: str,
72
+ cover: Optional[str] = None,
73
+ author: Optional[str] = None,
74
+ base_dir: Optional[Path] = None,
75
+ ) -> str:
76
+ """一站式将排版好的富文本文章上传并发布至微信公众号草稿箱。
77
+
78
+ 1. 获取官方 Access Token;
79
+ 2. 自动上传文中相对/绝对本地图片到微信 CDN 并替换 HTML 中的 src;
80
+ 3. 上传指定封面(或首图)作为永久封面素材;
81
+ 4. 提交草稿箱,返回 media_id。
82
+ """
83
+ token = get_access_token(appid, secret)
84
+ processed_html = html
85
+
86
+ # 1. 上传文中本地图片至微信 CDN 图床
87
+ uploaded_count = 0
88
+ for img_src in images:
89
+ if img_src.startswith(("http://", "https://", "data:")):
90
+ continue
91
+
92
+ img_path = Path(img_src)
93
+ if not img_path.is_absolute() and base_dir:
94
+ img_path = (base_dir / img_src).resolve()
95
+
96
+ if img_path.exists():
97
+ wechat_cdn_url = upload_article_image(token, str(img_path))
98
+ processed_html = processed_html.replace(img_src, wechat_cdn_url)
99
+ uploaded_count += 1
100
+
101
+ # 2. 准备图文封面素材 thumb_media_id
102
+ cover_path: Optional[Path] = None
103
+ if cover:
104
+ p = Path(cover)
105
+ if not p.is_absolute() and base_dir:
106
+ p = (base_dir / cover).resolve()
107
+ if p.exists():
108
+ cover_path = p
109
+ else:
110
+ raise FileNotFoundError(f"未找到指定的封面图片文件: {cover}")
111
+ elif images:
112
+ # 回退:使用文中第一张本地图片作为封面
113
+ for img_src in images:
114
+ if not img_src.startswith(("http://", "https://", "data:")):
115
+ p = Path(img_src)
116
+ if not p.is_absolute() and base_dir:
117
+ p = (base_dir / img_src).resolve()
118
+ if p.exists():
119
+ cover_path = p
120
+ break
121
+
122
+ if not cover_path:
123
+ raise ValueError("微信公众号官方要求每篇草稿必须有封面图,请使用 --cover 指定封面图片路径。")
124
+
125
+ thumb_media_id = upload_cover_material(token, str(cover_path))
126
+
127
+ # 3. 创建图文草稿
128
+ draft = create_draft(
129
+ access_token=token,
130
+ title=title,
131
+ html=processed_html,
132
+ digest=digest,
133
+ thumb_media_id=thumb_media_id,
134
+ author=author,
135
+ )
136
+ return draft.media_id
md2wx/styler.py ADDED
@@ -0,0 +1,288 @@
1
+ """WeChat inline style engine, DOM mutator, and platform sanitizer."""
2
+
3
+ import re
4
+ from typing import Optional
5
+ from bs4 import BeautifulSoup
6
+
7
+ from .theme import Theme
8
+
9
+
10
+ def apply_inline_styles(soup: BeautifulSoup, theme: Theme, css_rules: dict) -> None:
11
+ """Inject computed inline CSS and enhance theme-specific components."""
12
+ typo = getattr(theme, "typography", {}) or {}
13
+ comp = getattr(theme, "components", {}) or {}
14
+ colors = getattr(theme, "colors", {}) or {}
15
+
16
+ font_heading = typo.get("font_heading", "")
17
+
18
+ # 1. 应用基础 CSS 选择器规则
19
+ for selector, styles in css_rules.items():
20
+ sel = selector.strip()
21
+ if sel == "body":
22
+ continue
23
+ try:
24
+ elements = soup.select(sel)
25
+ except Exception:
26
+ continue
27
+ for elem in elements:
28
+ if sel == "code" and elem.find_parent("pre"):
29
+ continue
30
+ existing = elem.get("style", "")
31
+ sdict = {}
32
+ if existing:
33
+ for item in existing.split(";"):
34
+ if ":" in item:
35
+ k, v = item.split(":", 1)
36
+ sdict[k.strip()] = v.strip()
37
+ for prop, val in styles.items():
38
+ if prop not in sdict:
39
+ sdict[prop] = val
40
+ elem["style"] = "; ".join(f"{k}: {v}" for k, v in sdict.items())
41
+
42
+ # 2. 标题独立字体栈与装饰模式增强
43
+ h2_comp = comp.get("h2", {}) or comp.get("heading_h2", {})
44
+ h2_style = h2_comp.get("style", "")
45
+ h2_divider = h2_comp.get("section_divider", False)
46
+
47
+ for tag in ["h1", "h2", "h3"]:
48
+ for el in soup.find_all(tag):
49
+ est = el.get("style", "")
50
+ if font_heading and "font-family" not in est:
51
+ el["style"] = f"font-family: {font_heading}; {est}" if est else f"font-family: {font_heading}"
52
+
53
+ if tag == "h2":
54
+ if h2_style == "none":
55
+ st = el.get("style", "")
56
+ st = re.sub(r"border-left:[^;]+;?", "", st)
57
+ st = re.sub(r"padding-left:[^;]+;?", "", st)
58
+ el["style"] = f"{st}; border: none !important; padding: 0 !important"
59
+ elif h2_style == "border-bottom":
60
+ pcolor = h2_comp.get("color", colors.get("primary", "#2563eb"))
61
+ el["style"] = f"{el.get('style', '')}; border-bottom: 2px solid {pcolor}; padding-bottom: 8px;"
62
+
63
+ if h2_divider:
64
+ prev = el.find_previous_sibling()
65
+ if prev and prev.name not in ("h1", "hr"):
66
+ hr = soup.new_tag("section", style="border-top: 1px solid #e2e8f0; height: 0; margin: 32px 0 16px 0; padding: 0;")
67
+ el.insert_before(hr)
68
+
69
+ # 3. strong 加粗字体独立对比色注入
70
+ strong_comp = comp.get("strong", {})
71
+ strong_color = strong_comp.get("color", "")
72
+ strong_weight = strong_comp.get("font_weight", 600)
73
+ if strong_color:
74
+ for s_tag in soup.find_all("strong"):
75
+ est = s_tag.get("style", "")
76
+ s_tag["style"] = f"color: {strong_color} !important; font-weight: {strong_weight}; {est}" if est else f"color: {strong_color} !important; font-weight: {strong_weight}"
77
+
78
+ # 4. 高亮卡片/引用块 (callout) 居中卡片样式
79
+ callout_comp = comp.get("callout", {})
80
+ if callout_comp.get("style") == "center-card":
81
+ c_bg = callout_comp.get("background", "#eff6ff")
82
+ c_color = callout_comp.get("color", "#2563eb")
83
+ c_rad = callout_comp.get("border_radius", "8px")
84
+ for bq in soup.find_all("blockquote"):
85
+ bq["style"] = f"background: {c_bg} !important; color: {c_color} !important; border-radius: {c_rad} !important; border: none !important; padding: 16px 20px !important; margin: 20px 0 !important; text-align: center !important;"
86
+ for p_child in bq.find_all("p"):
87
+ p_child["style"] = f"color: {c_color} !important; margin: 0 !important; text-align: center !important; font-weight: 600 !important;"
88
+
89
+ # 5. 居中装帧分隔符 (✦ / ✧ / * * *)
90
+ for p in soup.find_all("p"):
91
+ txt = p.get_text(strip=True)
92
+ if txt in ("✦", "✧", "◆", "◇", "* * *"):
93
+ p["style"] = "text-align: center !important; font-size: 13px !important; color: #cbd5e1 !important; margin: 32px 0 24px 0 !important; letter-spacing: 4px;"
94
+
95
+ # 6. 插图外层弥散阴影与圆角
96
+ img_comp = comp.get("image", {})
97
+ img_shadow = img_comp.get("box_shadow", "")
98
+ img_rad = img_comp.get("border_radius", "")
99
+ if img_shadow or img_rad:
100
+ for img in soup.find_all("img"):
101
+ est = img.get("style", "")
102
+ extra = []
103
+ if img_shadow and "box-shadow" not in est:
104
+ extra.append(f"box-shadow: {img_shadow}")
105
+ if img_rad and "border-radius" not in est:
106
+ extra.append(f"border-radius: {img_rad}")
107
+ if extra:
108
+ img["style"] = f"{est}; {'; '.join(extra)}" if est else "; ".join(extra)
109
+
110
+ # 7. 代码块横向滚动保障
111
+ for pre in soup.find_all("pre"):
112
+ for code in pre.find_all("code"):
113
+ code["style"] = (
114
+ "display: block !important; width: max-content !important; min-width: 100% !important; "
115
+ "max-width: none !important; white-space: pre !important; word-wrap: normal !important; "
116
+ "word-break: normal !important; text-align: left !important; background: transparent !important; "
117
+ "color: inherit !important; padding: 0 !important; border: none !important; "
118
+ "font-size: inherit; font-family: inherit; line-height: inherit"
119
+ )
120
+
121
+
122
+ def preserve_code_block_newlines(soup: BeautifulSoup) -> None:
123
+ """为 <pre> 代码块内的换行符补充 <br/>,防止微信入库和回显时塌缩为单行。"""
124
+ for pre in soup.find_all("pre"):
125
+ for text_node in list(pre.find_all(string=True)):
126
+ if "\n" in text_node:
127
+ parts = text_node.split("\n")
128
+ curr = text_node
129
+ for idx, part in enumerate(parts):
130
+ if part:
131
+ s_node = soup.new_string(part)
132
+ curr.insert_after(s_node)
133
+ curr = s_node
134
+ if idx < len(parts) - 1:
135
+ br_node = soup.new_tag("br")
136
+ curr.insert_after(br_node)
137
+ curr = br_node
138
+ text_node.extract()
139
+
140
+
141
+ def apply_wechat_fixes(soup: BeautifulSoup, text_color: str = "#333333") -> None:
142
+ """修复微信端特定标签与段落样式。"""
143
+ for p in soup.find_all("p"):
144
+ style = p.get("style", "")
145
+ if "color" not in style:
146
+ p["style"] = f"{style}; color: {text_color}" if style else f"color: {text_color}"
147
+
148
+ for pre in soup.find_all("pre"):
149
+ style = pre.get("style", "")
150
+ sdict = {}
151
+ if style:
152
+ for item in style.split(";"):
153
+ if ":" in item:
154
+ k, v = item.split(":", 1)
155
+ sdict[k.strip()] = v.strip()
156
+ sdict["overflow-x"] = "auto !important"
157
+ sdict["overflow-y"] = "hidden"
158
+ sdict["white-space"] = "pre !important"
159
+ sdict["word-wrap"] = "normal !important"
160
+ sdict["word-break"] = "normal !important"
161
+ sdict["text-align"] = "left !important"
162
+ sdict["-webkit-overflow-scrolling"] = "touch"
163
+ sdict["box-sizing"] = "border-box"
164
+ pre["style"] = "; ".join(f"{k}: {v}" for k, v in sdict.items())
165
+
166
+ for span in pre.find_all("span"):
167
+ est = span.get("style", "")
168
+ if "text-align" not in est:
169
+ span["style"] = f"{est}; text-align: left !important; white-space: pre !important; word-wrap: normal !important" if est else "text-align: left !important; white-space: pre !important; word-wrap: normal !important"
170
+
171
+
172
+ def convert_lists_to_sections(soup: BeautifulSoup, text_color: str, primary: str) -> None:
173
+ """将无序与有序列表转为稳定的 Section Flex 容器。"""
174
+ for ul in soup.find_all("ul"):
175
+ sec = soup.new_tag("section")
176
+ for li in ul.find_all("li", recursive=False):
177
+ item = soup.new_tag("section", style=f"display: flex; align-items: flex-start; margin-bottom: 8px; color: {text_color}")
178
+ bullet = soup.new_tag("span", style=f"color: {primary}; margin-right: 8px; flex-shrink: 0; font-size: 18px; line-height: 1.6")
179
+ bullet.string = "•"
180
+ content = soup.new_tag("span", style="flex: 1")
181
+ for c in list(li.children):
182
+ content.append(c.extract() if hasattr(c, "extract") else c)
183
+ item.append(bullet)
184
+ item.append(content)
185
+ sec.append(item)
186
+ ul.replace_with(sec)
187
+
188
+ for ol in soup.find_all("ol"):
189
+ sec = soup.new_tag("section")
190
+ for num, li in enumerate(ol.find_all("li", recursive=False), 1):
191
+ item = soup.new_tag("section", style=f"display: flex; align-items: flex-start; margin-bottom: 8px; color: {text_color}")
192
+ number = soup.new_tag("span", style=f"color: {primary}; margin-right: 8px; flex-shrink: 0; font-weight: 700; line-height: 1.8")
193
+ number.string = f"{num}."
194
+ content = soup.new_tag("span", style="flex: 1")
195
+ for c in list(li.children):
196
+ content.append(c.extract() if hasattr(c, "extract") else c)
197
+ item.append(number)
198
+ item.append(content)
199
+ sec.append(item)
200
+ ol.replace_with(sec)
201
+
202
+
203
+ def convert_links_to_footnotes(soup: BeautifulSoup, primary: str) -> None:
204
+ """将文本中的外部超链接提取为文末上标脚注。"""
205
+ footnotes = []
206
+ counter = 0
207
+
208
+ for a in soup.find_all("a"):
209
+ href = a.get("href", "")
210
+ if not href or href.startswith("#"):
211
+ continue
212
+ counter += 1
213
+ text = a.get_text()
214
+ footnotes.append((counter, text, href))
215
+ sup = soup.new_tag("sup")
216
+ sup_link = soup.new_tag("span", style=f"color: {primary}; font-size: 12px")
217
+ sup_link.string = f"[{counter}]"
218
+ sup.append(sup_link)
219
+ a.replace_with(text, sup)
220
+
221
+ if footnotes:
222
+ hr = soup.new_tag("hr", style="border: none; border-top: 1px solid #e5e5e5; margin: 32px 0 16px")
223
+ soup.append(hr)
224
+ ref_title = soup.new_tag("p", style="font-size: 13px; color: #999999; margin-bottom: 8px; font-weight: 700")
225
+ ref_title.string = "参考链接"
226
+ soup.append(ref_title)
227
+ for num, text, href in footnotes:
228
+ ref = soup.new_tag("p", style="font-size: 12px; color: #999999; margin: 2px 0; word-break: break-all")
229
+ ref.string = f"[{num}] {text}: {href}"
230
+ soup.append(ref)
231
+
232
+
233
+ def inject_darkmode(soup: BeautifulSoup, darkmode_config: dict) -> None:
234
+ """注入微信专用暗黑模式数据属性。"""
235
+ if not darkmode_config:
236
+ return
237
+ for tag in ("p", "span", "section"):
238
+ for el in soup.find_all(tag):
239
+ if "color" in el.get("style", ""):
240
+ el["data-darkmode-color"] = darkmode_config.get("text", "#c8c8c8")
241
+ el["data-darkmode-bgcolor"] = "transparent"
242
+ for tag in ("h1", "h2", "h3", "h4"):
243
+ for el in soup.find_all(tag):
244
+ el["data-darkmode-color"] = darkmode_config.get("text", "#e0e0e0")
245
+ el["data-darkmode-bgcolor"] = "transparent"
246
+ for pre in soup.find_all("pre"):
247
+ pre["data-darkmode-bgcolor"] = darkmode_config.get("code_bg", "#2d2d2d")
248
+ pre["data-darkmode-color"] = darkmode_config.get("code_color", "#d4d4d4")
249
+ for code in soup.find_all("code"):
250
+ code["data-darkmode-color"] = darkmode_config.get("code_color", "#d4d4d4")
251
+ for bq in soup.find_all("blockquote"):
252
+ bq["data-darkmode-bgcolor"] = darkmode_config.get("quote_bg", "#2a2a2a")
253
+ bq["data-darkmode-color"] = darkmode_config.get("text", "#c8c8c8")
254
+
255
+
256
+ def sanitize_for_wechat(soup: BeautifulSoup) -> None:
257
+ """清洗不合规标签、class 与 id 属性。"""
258
+ for div in soup.find_all("div"):
259
+ div.name = "section"
260
+ for el in soup.find_all(attrs={"class": True}):
261
+ del el["class"]
262
+ for el in soup.find_all(attrs={"id": True}):
263
+ del el["id"]
264
+
265
+
266
+ def make_paste_safe(html: str) -> str:
267
+ """给所有非代码文本节点包裹 span[leaf],为空装饰元素补充占位。"""
268
+ soup = BeautifulSoup(html, "html.parser")
269
+ for node in list(soup.find_all(string=True)):
270
+ if not str(node).strip() or node.find_parent(["pre", "code"]) is not None:
271
+ continue
272
+ parent = node.parent
273
+ if parent and parent.name == "span" and parent.has_attr("leaf"):
274
+ continue
275
+ wrap = soup.new_tag("span")
276
+ wrap["leaf"] = ""
277
+ node.wrap(wrap)
278
+
279
+ for el in soup.find_all(["section", "span"]):
280
+ if el.find_parent(["pre", "code"]) is not None or el.name in ["pre", "code"]:
281
+ continue
282
+ if not el.get_text(strip=True) and not el.find(["img", "br"]) and not (el.name == "span" and el.has_attr("leaf")):
283
+ ph = soup.new_tag("span")
284
+ ph["leaf"] = ""
285
+ ph.append(soup.new_tag("br"))
286
+ el.append(ph)
287
+
288
+ return str(soup)