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/__init__.py +3 -0
- md2wx/cli.py +299 -0
- md2wx/clipboard.py +169 -0
- md2wx/config.py +83 -0
- md2wx/containers.py +177 -0
- md2wx/converter.py +206 -0
- md2wx/fetcher.py +211 -0
- md2wx/gallery.py +199 -0
- md2wx/publisher.py +136 -0
- md2wx/styler.py +288 -0
- md2wx/theme.py +117 -0
- md2wx/theme_extractor.py +295 -0
- md2wx/themes/bauhaus.yaml +207 -0
- md2wx/themes/bold-green.yaml +198 -0
- md2wx/themes/bold-navy.yaml +197 -0
- md2wx/themes/default.yaml +217 -0
- md2wx/themes/github-tech.yaml +104 -0
- md2wx/validator.py +166 -0
- md2wx/wechat_api.py +141 -0
- md2wx_cli-0.2.0.dist-info/METADATA +184 -0
- md2wx_cli-0.2.0.dist-info/RECORD +25 -0
- md2wx_cli-0.2.0.dist-info/WHEEL +5 -0
- md2wx_cli-0.2.0.dist-info/entry_points.txt +2 -0
- md2wx_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- md2wx_cli-0.2.0.dist-info/top_level.txt +1 -0
md2wx/theme.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Theme system for md2wx — YAML theme loading & CSS rule compilation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import cssutils
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
cssutils.log.setLevel(logging.CRITICAL)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Theme:
|
|
18
|
+
"""A theme definition with typography, component styles, colors and base CSS."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
base_css: str
|
|
23
|
+
colors: dict = field(default_factory=dict)
|
|
24
|
+
typography: dict = field(default_factory=dict)
|
|
25
|
+
components: dict = field(default_factory=dict)
|
|
26
|
+
_raw_data: dict = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_themes_dir() -> Path:
|
|
30
|
+
"""Return the built-in themes directory."""
|
|
31
|
+
return Path(__file__).parent / "themes"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _user_themes_dir() -> Path:
|
|
35
|
+
"""Return user custom themes directory (~/.md2wx/themes)."""
|
|
36
|
+
p = Path.home() / ".md2wx" / "themes"
|
|
37
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
return p
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _theme_search_dirs(custom_dir: Optional[str] = None) -> List[Path]:
|
|
42
|
+
"""Search priority: explicit dir > user themes > built-in themes."""
|
|
43
|
+
if custom_dir:
|
|
44
|
+
return [Path(custom_dir)]
|
|
45
|
+
return [_user_themes_dir(), _default_themes_dir()]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_theme(name: str, themes_dir: Optional[str] = None) -> Theme:
|
|
49
|
+
"""Load a theme by name from YAML files."""
|
|
50
|
+
for d in _theme_search_dirs(themes_dir):
|
|
51
|
+
candidate = d / f"{name}.yaml"
|
|
52
|
+
if candidate.exists():
|
|
53
|
+
data = yaml.safe_load(candidate.read_text(encoding="utf-8"))
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
raise ValueError(f"Invalid theme file: {candidate}")
|
|
56
|
+
theme = Theme(
|
|
57
|
+
name=data.get("name", name),
|
|
58
|
+
description=data.get("description", ""),
|
|
59
|
+
base_css=data.get("base_css", ""),
|
|
60
|
+
colors=data.get("colors", {}) if isinstance(data.get("colors"), dict) else {},
|
|
61
|
+
typography=data.get("typography", {}) if isinstance(data.get("typography"), dict) else {},
|
|
62
|
+
components=data.get("components", {}) if isinstance(data.get("components"), dict) else {},
|
|
63
|
+
_raw_data=data,
|
|
64
|
+
)
|
|
65
|
+
return theme
|
|
66
|
+
|
|
67
|
+
raise FileNotFoundError(f"Theme '{name}' not found. Use 'md2wx themes' to list available themes.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_themes(themes_dir: Optional[str] = None) -> List[str]:
|
|
71
|
+
"""List all available theme names."""
|
|
72
|
+
names = set()
|
|
73
|
+
for d in _theme_search_dirs(themes_dir):
|
|
74
|
+
if d.is_dir():
|
|
75
|
+
for f in d.glob("*.yaml"):
|
|
76
|
+
names.add(f.stem)
|
|
77
|
+
for f in d.glob("*.yml"):
|
|
78
|
+
names.add(f.stem)
|
|
79
|
+
return sorted(names)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_css_variables(css_text: str, colors: dict) -> str:
|
|
83
|
+
"""Replace var(--xxx) references with actual color values."""
|
|
84
|
+
|
|
85
|
+
def replacer(match: re.Match) -> str:
|
|
86
|
+
var_name = match.group(1).strip().lstrip("-")
|
|
87
|
+
key_underscore = var_name.replace("-", "_")
|
|
88
|
+
if var_name in colors:
|
|
89
|
+
return str(colors[var_name])
|
|
90
|
+
if key_underscore in colors:
|
|
91
|
+
return str(colors[key_underscore])
|
|
92
|
+
return match.group(0)
|
|
93
|
+
|
|
94
|
+
return re.sub(r"var\(\s*--([a-zA-Z0-9_-]+)\s*\)", replacer, css_text)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_inline_css_rules(theme: Theme) -> Dict[str, Dict[str, str]]:
|
|
98
|
+
"""Parse theme CSS into a selector -> style_dict mapping for inlining."""
|
|
99
|
+
css_text = _resolve_css_variables(theme.base_css, theme.colors)
|
|
100
|
+
sheet = cssutils.parseString(css_text)
|
|
101
|
+
rules: Dict[str, Dict[str, str]] = {}
|
|
102
|
+
|
|
103
|
+
for rule in sheet:
|
|
104
|
+
if rule.type == rule.STYLE_RULE:
|
|
105
|
+
for selector in rule.selectorList:
|
|
106
|
+
sel_text = selector.selectorText.strip()
|
|
107
|
+
# 排除伪类与媒体查询等无法内联的选择器
|
|
108
|
+
if any(ch in sel_text for ch in [":", "@"]):
|
|
109
|
+
continue
|
|
110
|
+
style_dict = {}
|
|
111
|
+
for prop in rule.style:
|
|
112
|
+
style_dict[prop.name] = prop.value
|
|
113
|
+
if sel_text not in rules:
|
|
114
|
+
rules[sel_text] = {}
|
|
115
|
+
rules[sel_text].update(style_dict)
|
|
116
|
+
|
|
117
|
+
return rules
|
md2wx/theme_extractor.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""主题逆向提取引擎 —— 从微信推文 URL 或本地设计快照目录提取专属排版主题。"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import urllib.request
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
|
|
12
|
+
GITHUB_TECH_FALLBACK = {
|
|
13
|
+
"font_body": '"PingFang SC", "Microsoft YaHei", -apple-system, sans-serif',
|
|
14
|
+
"font_heading": '"PingFang SC", "Microsoft YaHei", -apple-system, sans-serif',
|
|
15
|
+
"canvas_bg": "#ffffff",
|
|
16
|
+
"text_color": "#24292f",
|
|
17
|
+
"line_height": "1.7",
|
|
18
|
+
"h2_color": "#24292f",
|
|
19
|
+
"h2_size": "22px",
|
|
20
|
+
"h2_style": "border-bottom",
|
|
21
|
+
"h2_divider": False,
|
|
22
|
+
"strong_color": "#24292f",
|
|
23
|
+
"callout_style": "left-bar",
|
|
24
|
+
"callout_bg": "#f6f8fa",
|
|
25
|
+
"callout_color": "#57606a",
|
|
26
|
+
"callout_rad": "6px",
|
|
27
|
+
"img_shadow": "0 1px 3px rgba(0,0,0,0.12)",
|
|
28
|
+
"img_rad": "6px",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rgb_to_hex(rgb_str: str) -> str:
|
|
33
|
+
if not rgb_str:
|
|
34
|
+
return ""
|
|
35
|
+
s = rgb_str.strip()
|
|
36
|
+
if re.match(r"^#[0-9a-fA-F]{3,8}$", s):
|
|
37
|
+
return s.lower()
|
|
38
|
+
m = re.match(r"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*[\d.]+)?\s*\)", s, re.I)
|
|
39
|
+
return f"#{int(m.group(1)):02x}{int(m.group(2)):02x}{int(m.group(3)):02x}" if m else s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _clean_font_family(font_str: str) -> str:
|
|
43
|
+
if not font_str:
|
|
44
|
+
return ""
|
|
45
|
+
cleaned = []
|
|
46
|
+
for f in [f.strip(" '\"") for f in font_str.split(",") if f.strip()]:
|
|
47
|
+
cleaned.append(f'"{f}"' if " " in f and not (f.startswith('"') and f.endswith('"')) else f)
|
|
48
|
+
return ", ".join(cleaned)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_inline_style(style_str: str) -> dict:
|
|
52
|
+
res = {}
|
|
53
|
+
if not style_str:
|
|
54
|
+
return res
|
|
55
|
+
for item in style_str.split(";"):
|
|
56
|
+
if ":" in item:
|
|
57
|
+
k, v = item.split(":", 1)
|
|
58
|
+
res[k.strip().lower()] = v.strip().replace("!important", "").strip()
|
|
59
|
+
return res
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_from_url(url: str, theme_name: str = "custom-theme") -> str:
|
|
63
|
+
"""从在线微信文章 URL 抓取并提取主题 YAML。"""
|
|
64
|
+
req = urllib.request.Request(
|
|
65
|
+
url,
|
|
66
|
+
headers={"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.38 NetType/WIFI Language/zh_CN", "Referer": "https://mp.weixin.qq.com"},
|
|
67
|
+
)
|
|
68
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
69
|
+
content_bytes = resp.read()
|
|
70
|
+
try:
|
|
71
|
+
html = content_bytes.decode("utf-8")
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
html = content_bytes.decode("gbk", errors="ignore")
|
|
74
|
+
if "当前环境异常" in html and "js_content" not in html:
|
|
75
|
+
raise ValueError("该微信链接触发了反爬验证,建议将网页保存为 HTML 后使用 --file 或 --dir 提取。")
|
|
76
|
+
return _extract_from_html_string(html, source_name="微信在线文章", theme_name=theme_name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_from_dir(dir_path: Path, theme_name: str = "custom-theme") -> str:
|
|
80
|
+
"""从本地快照目录提取主题(优先识别 HTML 文件,备选 DESIGN.md)。"""
|
|
81
|
+
if not dir_path.exists() or not dir_path.is_dir():
|
|
82
|
+
raise FileNotFoundError(f"目录不存在: {dir_path}")
|
|
83
|
+
candidates = [dir_path / "index.html", dir_path / "article.html"] + list(dir_path.glob("*.html"))
|
|
84
|
+
for cand in candidates:
|
|
85
|
+
if cand.exists():
|
|
86
|
+
return _extract_from_html_string(cand.read_text(encoding="utf-8-sig"), source_name=dir_path.name, theme_name=theme_name)
|
|
87
|
+
design_md = dir_path / "DESIGN.md"
|
|
88
|
+
if design_md.exists():
|
|
89
|
+
return _extract_from_design_md(design_md, theme_name=theme_name)
|
|
90
|
+
raise FileNotFoundError(f"目录 {dir_path} 中未找到 HTML 或 DESIGN.md 样式文件")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_from_file(file_path: Path, theme_name: str = "custom-theme") -> str:
|
|
94
|
+
"""从单个本地 HTML 或 DESIGN.md 文件提取主题。"""
|
|
95
|
+
if not file_path.exists() or not file_path.is_file():
|
|
96
|
+
raise FileNotFoundError(f"文件不存在: {file_path}")
|
|
97
|
+
if file_path.name.lower() == "design.md":
|
|
98
|
+
return _extract_from_design_md(file_path, theme_name=theme_name)
|
|
99
|
+
return _extract_from_html_string(file_path.read_text(encoding="utf-8-sig"), source_name=file_path.name, theme_name=theme_name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_from_html_string(html_content: str, source_name: str, theme_name: str) -> str:
|
|
103
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
104
|
+
content_el = soup.find(id="js_content") or soup.find(class_="rich_media_content") or soup
|
|
105
|
+
|
|
106
|
+
canvas_bg = GITHUB_TECH_FALLBACK["canvas_bg"]
|
|
107
|
+
top_sec = content_el.find("section")
|
|
108
|
+
if top_sec and top_sec.get("style"):
|
|
109
|
+
st = _parse_inline_style(top_sec.get("style", ""))
|
|
110
|
+
bg = st.get("background") or st.get("background-color")
|
|
111
|
+
if bg and not bg.startswith("url"):
|
|
112
|
+
hex_bg = _rgb_to_hex(bg)
|
|
113
|
+
if hex_bg and hex_bg not in ("transparent", "#ffffff"):
|
|
114
|
+
canvas_bg = hex_bg
|
|
115
|
+
|
|
116
|
+
p_tags = content_el.find_all("p")
|
|
117
|
+
p_colors, p_fonts, p_lhs = Counter(), Counter(), Counter()
|
|
118
|
+
for p in p_tags:
|
|
119
|
+
st = _parse_inline_style(p.get("style", ""))
|
|
120
|
+
if "color" in st and _rgb_to_hex(st["color"]):
|
|
121
|
+
p_colors[_rgb_to_hex(st["color"])] += 1
|
|
122
|
+
if "font-family" in st:
|
|
123
|
+
p_fonts[st["font-family"]] += 1
|
|
124
|
+
if "line-height" in st:
|
|
125
|
+
p_lhs[st["line-height"]] += 1
|
|
126
|
+
|
|
127
|
+
text_color = p_colors.most_common(1)[0][0] if p_colors else GITHUB_TECH_FALLBACK["text_color"]
|
|
128
|
+
font_body = p_fonts.most_common(1)[0][0] if p_fonts else GITHUB_TECH_FALLBACK["font_body"]
|
|
129
|
+
line_height = p_lhs.most_common(1)[0][0] if p_lhs else GITHUB_TECH_FALLBACK["line_height"]
|
|
130
|
+
|
|
131
|
+
h2_tags = content_el.find_all("h2")
|
|
132
|
+
font_heading = font_body
|
|
133
|
+
h2_style, h2_color, h2_size, h2_divider = GITHUB_TECH_FALLBACK["h2_style"], GITHUB_TECH_FALLBACK["h2_color"], GITHUB_TECH_FALLBACK["h2_size"], False
|
|
134
|
+
|
|
135
|
+
if h2_tags:
|
|
136
|
+
for h2 in h2_tags:
|
|
137
|
+
st = _parse_inline_style(h2.get("style", ""))
|
|
138
|
+
if "font-family" in st:
|
|
139
|
+
font_heading = st["font-family"]
|
|
140
|
+
if "color" in st:
|
|
141
|
+
h2_color = _rgb_to_hex(st["color"])
|
|
142
|
+
if "font-size" in st:
|
|
143
|
+
h2_size = st["font-size"]
|
|
144
|
+
if "border-left" in st or "border-left-width" in st:
|
|
145
|
+
h2_style = "border-left"
|
|
146
|
+
elif "border-bottom" in st or "border-bottom-width" in st:
|
|
147
|
+
h2_style = "border-bottom"
|
|
148
|
+
else:
|
|
149
|
+
h2_style = "none"
|
|
150
|
+
|
|
151
|
+
prev_sec = h2.find_parent("section")
|
|
152
|
+
if prev_sec:
|
|
153
|
+
prev_sibling = prev_sec.find_previous_sibling("section")
|
|
154
|
+
if prev_sibling and ("border-top" in prev_sibling.get("style", "") or prev_sibling.find("hr")):
|
|
155
|
+
h2_divider = True
|
|
156
|
+
|
|
157
|
+
strong_tags = content_el.find_all("strong")
|
|
158
|
+
strong_color = h2_color if h2_color != text_color else GITHUB_TECH_FALLBACK["strong_color"]
|
|
159
|
+
if strong_tags:
|
|
160
|
+
st_colors = Counter(_rgb_to_hex(_parse_inline_style(el.get("style", "")).get("color", "")) for el in strong_tags if _parse_inline_style(el.get("style", "")).get("color"))
|
|
161
|
+
if st_colors:
|
|
162
|
+
strong_color = st_colors.most_common(1)[0][0]
|
|
163
|
+
|
|
164
|
+
callout_style, callout_bg, callout_color, callout_rad = GITHUB_TECH_FALLBACK["callout_style"], GITHUB_TECH_FALLBACK["callout_bg"], GITHUB_TECH_FALLBACK["callout_color"], GITHUB_TECH_FALLBACK["callout_rad"]
|
|
165
|
+
for sec in content_el.find_all("section"):
|
|
166
|
+
st = _parse_inline_style(sec.get("style", ""))
|
|
167
|
+
bg = st.get("background") or st.get("background-color")
|
|
168
|
+
if bg and _rgb_to_hex(bg) not in ("#ffffff", "#f8fafc", canvas_bg, "transparent"):
|
|
169
|
+
callout_bg = _rgb_to_hex(bg)
|
|
170
|
+
callout_rad = st.get("border-radius", "8px")
|
|
171
|
+
p_child = sec.find("p")
|
|
172
|
+
if p_child:
|
|
173
|
+
pst = _parse_inline_style(p_child.get("style", ""))
|
|
174
|
+
if "color" in pst:
|
|
175
|
+
callout_color = _rgb_to_hex(pst["color"])
|
|
176
|
+
if pst.get("text-align") == "center":
|
|
177
|
+
callout_style = "center-card"
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
img_shadow, img_rad = GITHUB_TECH_FALLBACK["img_shadow"], GITHUB_TECH_FALLBACK["img_rad"]
|
|
181
|
+
for img in content_el.find_all("img"):
|
|
182
|
+
st = _parse_inline_style(img.get("style", ""))
|
|
183
|
+
if "box-shadow" in st:
|
|
184
|
+
img_shadow = st["box-shadow"]
|
|
185
|
+
if "border-radius" in st:
|
|
186
|
+
img_rad = st["border-radius"]
|
|
187
|
+
if img_shadow != GITHUB_TECH_FALLBACK["img_shadow"]:
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
return _build_declarative_yaml(
|
|
191
|
+
theme_name=theme_name,
|
|
192
|
+
source_name=source_name,
|
|
193
|
+
font_body=font_body,
|
|
194
|
+
font_heading=font_heading,
|
|
195
|
+
canvas_bg=canvas_bg,
|
|
196
|
+
text_color=text_color,
|
|
197
|
+
line_height=line_height,
|
|
198
|
+
h2_color=h2_color,
|
|
199
|
+
h2_size=h2_size,
|
|
200
|
+
h2_style=h2_style,
|
|
201
|
+
h2_divider=h2_divider,
|
|
202
|
+
strong_color=strong_color,
|
|
203
|
+
callout_style=callout_style,
|
|
204
|
+
callout_bg=callout_bg,
|
|
205
|
+
callout_color=callout_color,
|
|
206
|
+
callout_rad=callout_rad,
|
|
207
|
+
img_shadow=img_shadow,
|
|
208
|
+
img_rad=img_rad,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _extract_from_design_md(design_md_path: Path, theme_name: str) -> str:
|
|
213
|
+
content = design_md_path.read_text(encoding="utf-8-sig")
|
|
214
|
+
frontmatter = {}
|
|
215
|
+
if content.startswith("---"):
|
|
216
|
+
parts = content.split("---", 2)
|
|
217
|
+
if len(parts) >= 3:
|
|
218
|
+
try:
|
|
219
|
+
frontmatter = yaml.safe_load(parts[1]) or {}
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
colors = frontmatter.get("colors", {})
|
|
224
|
+
text_color = _rgb_to_hex(str(colors.get("color-002", "#475569")))
|
|
225
|
+
h2_color = _rgb_to_hex(str(colors.get("color-007", "#0f172a")))
|
|
226
|
+
callout_color = _rgb_to_hex(str(colors.get("color-014", "#2563eb")))
|
|
227
|
+
canvas_bg = "#f8fafc" if "color-002" in colors else "#ffffff"
|
|
228
|
+
|
|
229
|
+
return _build_declarative_yaml(
|
|
230
|
+
theme_name=theme_name,
|
|
231
|
+
source_name=design_md_path.name,
|
|
232
|
+
font_body='"Noto Sans SC", "PingFang SC", -apple-system, sans-serif',
|
|
233
|
+
font_heading='"Noto Serif SC", "Songti SC", STSong, Georgia, serif',
|
|
234
|
+
canvas_bg=canvas_bg,
|
|
235
|
+
text_color=text_color,
|
|
236
|
+
line_height="1.9",
|
|
237
|
+
h2_color=h2_color,
|
|
238
|
+
h2_size="22px",
|
|
239
|
+
h2_style="none",
|
|
240
|
+
h2_divider=True,
|
|
241
|
+
strong_color=h2_color,
|
|
242
|
+
callout_style="center-card",
|
|
243
|
+
callout_bg="#eff6ff",
|
|
244
|
+
callout_color=callout_color,
|
|
245
|
+
callout_rad="8px",
|
|
246
|
+
img_shadow="0 4px 18px rgba(15, 23, 42, 0.08)",
|
|
247
|
+
img_rad="8px",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _build_declarative_yaml(
|
|
252
|
+
theme_name: str, source_name: str, font_body: str, font_heading: str, canvas_bg: str,
|
|
253
|
+
text_color: str, line_height: str, h2_color: str, h2_size: str, h2_style: str, h2_divider: bool,
|
|
254
|
+
strong_color: str, callout_style: str, callout_bg: str, callout_color: str, callout_rad: str,
|
|
255
|
+
img_shadow: str, img_rad: str
|
|
256
|
+
) -> str:
|
|
257
|
+
clean_body = _clean_font_family(font_body)
|
|
258
|
+
clean_heading = _clean_font_family(font_heading)
|
|
259
|
+
|
|
260
|
+
theme_dict = {
|
|
261
|
+
"name": theme_name,
|
|
262
|
+
"description": f"从 {source_name} 精准逆向提取的组件级排版主题",
|
|
263
|
+
"typography": {"font_body": clean_body, "font_heading": clean_heading, "font_code": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"},
|
|
264
|
+
"components": {
|
|
265
|
+
"heading_h2": {"style": h2_style, "color": h2_color, "font_size": h2_size, "section_divider": h2_divider},
|
|
266
|
+
"strong": {"color": strong_color, "font_weight": 600},
|
|
267
|
+
"callout": {"style": callout_style, "background": callout_bg, "color": callout_color, "border_radius": callout_rad},
|
|
268
|
+
"image": {"border_radius": img_rad, "box_shadow": img_shadow},
|
|
269
|
+
"canvas": {"background": canvas_bg},
|
|
270
|
+
},
|
|
271
|
+
"colors": {
|
|
272
|
+
"primary": h2_color, "secondary": callout_color, "text": text_color, "text_light": "#64748b",
|
|
273
|
+
"background": canvas_bg, "code_bg": "#1e293b", "code_color": "#e2e8f0",
|
|
274
|
+
"quote_border": callout_color, "quote_bg": callout_bg, "border_radius": img_rad,
|
|
275
|
+
"darkmode": {"background": "#090d16", "text": "#cbd5e1", "primary": "#f8fafc", "code_bg": "#0f172a", "code_color": "#e2e8f0", "quote_bg": "#172554"},
|
|
276
|
+
},
|
|
277
|
+
"base_css": f"""body {{\n font-family: {clean_body};\n font-size: 15px;\n line-height: {line_height};\n color: {text_color};\n background: {canvas_bg};\n max-width: 640px;\n margin: 0 auto;\n padding: 24px 16px;\n word-break: break-word;\n}}\nh1 {{\n font-family: {clean_heading};\n font-size: 24px;\n font-weight: 700;\n color: {h2_color};\n margin: 36px 0 20px 0;\n line-height: 1.4;\n}}\nh2 {{\n font-family: {clean_heading};\n font-size: {h2_size};\n font-weight: 700;\n color: {h2_color};\n margin: 36px 0 16px 0;\n line-height: 1.4;\n}}\np {{\n font-size: 15px;\n line-height: {line_height};\n color: {text_color};\n margin: 0 0 16px 0;\n}}\nstrong {{\n color: {strong_color};\n font-weight: 600;\n}}\nblockquote {{\n background: {callout_bg};\n border-radius: {callout_rad};\n border: none;\n padding: 16px 20px;\n margin: 20px 0;\n color: {callout_color};\n font-weight: 600;\n text-align: center;\n}}\nimg {{\n max-width: 100%;\n border-radius: {img_rad};\n display: block;\n margin: 20px auto;\n box-shadow: {img_shadow};\n}}\npre {{\n background: #1e293b;\n color: #e2e8f0;\n padding: 16px;\n border-radius: 8px;\n overflow-x: auto !important;\n margin: 20px 0;\n}}\n""",
|
|
278
|
+
}
|
|
279
|
+
return yaml.safe_dump(theme_dict, allow_unicode=True, sort_keys=False)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def save_theme_yaml(yaml_str: str, theme_name: str, target_path: Optional[Path] = None) -> Path:
|
|
283
|
+
"""将主题 YAML 保存至全局 ~/.md2wx/themes/ 或指定路径。"""
|
|
284
|
+
if target_path is None:
|
|
285
|
+
save_dir = Path.home() / ".md2wx" / "themes"
|
|
286
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
dest = save_dir / f"{theme_name}.yaml"
|
|
288
|
+
elif target_path.suffix == "":
|
|
289
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
dest = target_path / f"{theme_name}.yaml"
|
|
291
|
+
else:
|
|
292
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
dest = target_path
|
|
294
|
+
dest.write_text(yaml_str, encoding="utf-8")
|
|
295
|
+
return dest
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
name: "bauhaus"
|
|
2
|
+
description: "包豪斯设计风格:纯白底黑色为主,红蓝黄色块点缀,几何感强烈"
|
|
3
|
+
colors:
|
|
4
|
+
primary: "#e63226"
|
|
5
|
+
secondary: "#004592"
|
|
6
|
+
text: "#1a1a1a"
|
|
7
|
+
text_light: "#555555"
|
|
8
|
+
background: "#ffffff"
|
|
9
|
+
code_bg: "#f0f0f0"
|
|
10
|
+
code_color: "#004592"
|
|
11
|
+
quote_border: "#e63226"
|
|
12
|
+
quote_bg: "#fff5f5"
|
|
13
|
+
border_radius: "0px"
|
|
14
|
+
darkmode:
|
|
15
|
+
background: "#111111"
|
|
16
|
+
text: "#e8e8e8"
|
|
17
|
+
text_light: "#a0a0a0"
|
|
18
|
+
primary: "#f04438"
|
|
19
|
+
code_bg: "#222222"
|
|
20
|
+
code_color: "#5b9bd5"
|
|
21
|
+
quote_bg: "#1e1212"
|
|
22
|
+
quote_border: "#f04438"
|
|
23
|
+
base_css: |
|
|
24
|
+
body {
|
|
25
|
+
font-family: "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
26
|
+
font-size: 16px;
|
|
27
|
+
line-height: 1.75;
|
|
28
|
+
color: #1a1a1a;
|
|
29
|
+
background: #ffffff;
|
|
30
|
+
max-width: 720px;
|
|
31
|
+
margin: 0 auto;
|
|
32
|
+
padding: 20px;
|
|
33
|
+
word-wrap: break-word;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
h1 {
|
|
37
|
+
font-size: 30px;
|
|
38
|
+
font-weight: 900;
|
|
39
|
+
color: #1a1a1a;
|
|
40
|
+
margin: 36px 0 18px 0;
|
|
41
|
+
padding: 12px 16px;
|
|
42
|
+
background: #e63226;
|
|
43
|
+
color: #ffffff;
|
|
44
|
+
line-height: 1.3;
|
|
45
|
+
text-transform: uppercase;
|
|
46
|
+
letter-spacing: 0.04em;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
h2 {
|
|
50
|
+
font-size: 22px;
|
|
51
|
+
font-weight: 800;
|
|
52
|
+
color: #004592;
|
|
53
|
+
margin: 32px 0 14px 0;
|
|
54
|
+
padding: 8px 0;
|
|
55
|
+
border-bottom: 4px solid #1a1a1a;
|
|
56
|
+
line-height: 1.4;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
h3 {
|
|
60
|
+
font-size: 18px;
|
|
61
|
+
font-weight: 700;
|
|
62
|
+
color: #1a1a1a;
|
|
63
|
+
margin: 24px 0 12px 0;
|
|
64
|
+
padding-left: 12px;
|
|
65
|
+
border-left: 6px solid #f5b700;
|
|
66
|
+
line-height: 1.4;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
h4 {
|
|
70
|
+
font-size: 16px;
|
|
71
|
+
font-weight: 700;
|
|
72
|
+
color: #1a1a1a;
|
|
73
|
+
margin: 20px 0 10px 0;
|
|
74
|
+
line-height: 1.4;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
p {
|
|
78
|
+
font-size: 16px;
|
|
79
|
+
line-height: 1.75;
|
|
80
|
+
color: #1a1a1a;
|
|
81
|
+
margin: 12px 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
strong {
|
|
85
|
+
font-weight: 800;
|
|
86
|
+
color: #e63226;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
em {
|
|
90
|
+
font-style: italic;
|
|
91
|
+
color: #555555;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
code {
|
|
95
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
|
96
|
+
font-size: 14px;
|
|
97
|
+
background: #f0f0f0;
|
|
98
|
+
color: #004592;
|
|
99
|
+
padding: 2px 6px;
|
|
100
|
+
border-radius: 0px;
|
|
101
|
+
border: 1px solid #ddd;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pre {
|
|
105
|
+
background: #1a1a1a;
|
|
106
|
+
color: #f0f0f0;
|
|
107
|
+
padding: 16px;
|
|
108
|
+
border-radius: 0px;
|
|
109
|
+
overflow-x: auto;
|
|
110
|
+
margin: 16px 0;
|
|
111
|
+
line-height: 1.6;
|
|
112
|
+
border-left: 6px solid #e63226;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pre code {
|
|
116
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
|
117
|
+
font-size: 14px;
|
|
118
|
+
background: none;
|
|
119
|
+
color: #f0f0f0;
|
|
120
|
+
padding: 0;
|
|
121
|
+
border-radius: 0;
|
|
122
|
+
border: none;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
blockquote {
|
|
126
|
+
border-left: 6px solid #e63226;
|
|
127
|
+
background: #fff5f5;
|
|
128
|
+
margin: 16px 0;
|
|
129
|
+
padding: 12px 16px;
|
|
130
|
+
border-radius: 0;
|
|
131
|
+
color: #1a1a1a;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
blockquote p {
|
|
135
|
+
margin: 8px 0;
|
|
136
|
+
color: #1a1a1a;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ul {
|
|
140
|
+
padding-left: 24px;
|
|
141
|
+
margin: 12px 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
ol {
|
|
145
|
+
padding-left: 24px;
|
|
146
|
+
margin: 12px 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
li {
|
|
150
|
+
font-size: 16px;
|
|
151
|
+
line-height: 1.75;
|
|
152
|
+
color: #1a1a1a;
|
|
153
|
+
margin: 6px 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
table {
|
|
157
|
+
width: 100%;
|
|
158
|
+
border-collapse: collapse;
|
|
159
|
+
margin: 16px 0;
|
|
160
|
+
font-size: 15px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
thead {
|
|
164
|
+
background: #004592;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
th {
|
|
168
|
+
background: #004592;
|
|
169
|
+
color: #ffffff;
|
|
170
|
+
font-weight: 700;
|
|
171
|
+
padding: 10px 14px;
|
|
172
|
+
text-align: left;
|
|
173
|
+
border: 2px solid #1a1a1a;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
td {
|
|
177
|
+
padding: 10px 14px;
|
|
178
|
+
border: 2px solid #1a1a1a;
|
|
179
|
+
color: #1a1a1a;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
tr {
|
|
183
|
+
background: #ffffff;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
img {
|
|
187
|
+
max-width: 100%;
|
|
188
|
+
height: auto;
|
|
189
|
+
display: block;
|
|
190
|
+
margin: 24px auto;
|
|
191
|
+
border-radius: 0px;
|
|
192
|
+
border: 2px solid #1a1a1a;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
a {
|
|
196
|
+
color: #004592;
|
|
197
|
+
text-decoration: none;
|
|
198
|
+
font-weight: 700;
|
|
199
|
+
border-bottom: 2px solid #f5b700;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
hr {
|
|
203
|
+
border: none;
|
|
204
|
+
height: 4px;
|
|
205
|
+
background: #1a1a1a;
|
|
206
|
+
margin: 28px 0;
|
|
207
|
+
}
|