wewrite/toolkit/theme.py
wangzhuc 1ab34fa450 Initial release — 公众号文章全流程 AI Skill
热点抓取 → 选题 → 框架 → 写作 → SEO → 视觉AI → 排版 → 微信草稿箱,
一句话触发完整流程。适用于 Claude Code skill 格式。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:16:18 +08:00

197 lines
5.5 KiB
Python

"""
Theme system for media-agent.
Loads YAML theme definitions and provides CSS parsing utilities
for the inline style converter.
"""
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import cssutils
import yaml
# Suppress cssutils warnings (it's very noisy about non-standard properties)
cssutils.log.setLevel(logging.CRITICAL)
@dataclass
class Theme:
"""A theme definition with colors and base CSS."""
name: str
description: str
base_css: str
colors: dict = field(default_factory=dict)
def _default_themes_dir() -> str:
"""Return the themes/ directory relative to this file."""
return str(Path(__file__).parent / "themes")
def load_theme(name: str, themes_dir: str = None) -> Theme:
"""
Load a theme by name from a YAML file.
Args:
name: Theme name (without .yaml extension).
themes_dir: Directory containing theme YAML files.
Defaults to themes/ relative to this file.
Returns:
A Theme object.
Raises:
FileNotFoundError: If the theme YAML file does not exist.
ValueError: If the YAML is malformed or missing required fields.
"""
if themes_dir is None:
themes_dir = _default_themes_dir()
theme_path = os.path.join(themes_dir, f"{name}.yaml")
if not os.path.exists(theme_path):
raise FileNotFoundError(f"Theme file not found: {theme_path}")
with open(theme_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"Invalid theme file: {theme_path}")
required = ("name", "description", "base_css", "colors")
for key in required:
if key not in data:
raise ValueError(f"Theme file missing required field '{key}': {theme_path}")
return Theme(
name=data["name"],
description=data["description"],
base_css=data["base_css"],
colors=data.get("colors", {}),
)
def list_themes(themes_dir: str = None) -> list[str]:
"""
List available theme names.
Args:
themes_dir: Directory containing theme YAML files.
Defaults to themes/ relative to this file.
Returns:
Sorted list of theme names (without .yaml extension).
"""
if themes_dir is None:
themes_dir = _default_themes_dir()
if not os.path.isdir(themes_dir):
return []
names = []
for filename in os.listdir(themes_dir):
if filename.endswith(".yaml") or filename.endswith(".yml"):
names.append(filename.rsplit(".", 1)[0])
return sorted(names)
def _resolve_css_variables(css_text: str, colors: dict) -> str:
"""
Replace var(--xxx) references in CSS with actual color values.
Supports var(--primary), var(--secondary), etc. based on the
colors dict keys. The CSS variable name is mapped by stripping
the leading --.
"""
def replacer(match: re.Match) -> str:
var_name = match.group(1).strip()
# Strip leading -- prefix
key = var_name.lstrip("-")
# Also try with hyphens converted to underscores
key_underscore = key.replace("-", "_")
if key in colors:
return str(colors[key])
if key_underscore in colors:
return str(colors[key_underscore])
# Return original if not found
return match.group(0)
return re.sub(r"var\(\s*--([a-zA-Z0-9_-]+)\s*\)", replacer, css_text)
def _is_simple_selector(selector: str) -> bool:
"""
Check if a selector is simple enough for inline styling.
Rejects pseudo-classes, pseudo-elements, media queries,
and complex combinators.
"""
selector = selector.strip()
# Reject if contains any of these characters
reject_chars = (":", "@", ">", "+", "~", "[", "*")
for ch in reject_chars:
if ch in selector:
return False
return True
def get_inline_css_rules(theme: Theme) -> dict[str, dict[str, str]]:
"""
Parse a theme's base_css into a selector -> {property: value} dict.
This resolves CSS variable references using theme.colors, then
parses the CSS with cssutils. Only simple selectors are included
(no pseudo-classes, pseudo-elements, media queries, or complex
combinators).
Args:
theme: A Theme object with base_css and colors.
Returns:
Dict mapping CSS selectors to dicts of {property: value}.
Example: {"h1": {"color": "#333", "font-size": "28px"}, ...}
"""
# Resolve CSS variables first
resolved_css = _resolve_css_variables(theme.base_css, theme.colors)
# Parse with cssutils
sheet = cssutils.parseString(resolved_css, validate=False)
rules: dict[str, dict[str, str]] = {}
for rule in sheet:
if rule.type != rule.STYLE_RULE:
continue
selector_text = rule.selectorText
# A rule can have multiple comma-separated selectors
selectors = [s.strip() for s in selector_text.split(",")]
# Build property dict for this rule
props: dict[str, str] = {}
for prop in rule.style:
props[prop.name] = prop.value
if not props:
continue
for selector in selectors:
if not _is_simple_selector(selector):
continue
if selector in rules:
# Merge (later rules override)
rules[selector].update(props)
else:
rules[selector] = dict(props)
return rules