""" Multi-theme color and visual style manager for the universal data report generator. """ from pptx.dml.color import RGBColor from report_config import ThemeConfig, ThemePreset PRESETS = { ThemePreset.BUSINESS_CLASSIC: ThemeConfig( preset=ThemePreset.BUSINESS_CLASSIC, name='商务经典', primary='#1E3A5F', accent='#10B981', accent_neg='#EF4444', secondary='#64748B', dark='#1F3A5C', white='#FFFFFF', gray_bg='#F2F2F2', card_bg='#E7F0F7', text='#333333', text_gray='#666666', line='#D9D9D9', chart_series=[ '#1E3A5F', '#10B981', '#ED7D31', '#64748B', '#EF4444', '#707070', '#4472C4', '#5B9BD5', ], ), ThemePreset.FRESH_SIMPLE: ThemeConfig( preset=ThemePreset.FRESH_SIMPLE, name='清新简约', primary='#1B8A5E', accent='#10B981', accent_neg='#EF4444', secondary='#94A3B8', dark='#0F5C3B', white='#FFFFFF', gray_bg='#F8FAFC', card_bg='#ECFDF5', text='#1E293B', text_gray='#64748B', line='#E2E8F0', chart_series=[ '#1B8A5E', '#3B82F6', '#F59E0B', '#94A3B8', '#EF4444', '#8B5CF6', '#06B6D4', '#10B981', ], ), ThemePreset.DARK_PROFESSIONAL: ThemeConfig( preset=ThemePreset.DARK_PROFESSIONAL, name='深色专业', primary='#1E293B', accent='#38BDF8', accent_neg='#F87171', secondary='#94A3B8', dark='#0F172A', white='#FFFFFF', gray_bg='#F1F5F9', card_bg='#E2E8F0', text='#1E293B', text_gray='#475569', line='#CBD5E1', chart_series=[ '#1E293B', '#38BDF8', '#F59E0B', '#94A3B8', '#F87171', '#A78BFA', '#34D399', '#FB923C', ], ), ThemePreset.WARM_BRAND: ThemeConfig( preset=ThemePreset.WARM_BRAND, name='温暖品牌', primary='#C2410C', accent='#F97316', accent_neg='#DC2626', secondary='#78716C', dark='#7C2D12', white='#FFFFFF', gray_bg='#FFFBEB', card_bg='#FFF7ED', text='#292524', text_gray='#78716C', line='#D6D3D1', chart_series=[ '#C2410C', '#F97316', '#EAB308', '#78716C', '#DC2626', '#84CC16', '#06B6D4', '#A855F7', ], ), } def get_theme(preset: ThemePreset, custom_overrides: dict = None) -> ThemeConfig: if preset == ThemePreset.CUSTOM: config = ThemeConfig(preset=ThemePreset.CUSTOM, name='自定义主题') if custom_overrides: for k, v in custom_overrides.items(): if hasattr(config, k): setattr(config, k, v) return config return PRESETS.get(preset, PRESETS[ThemePreset.BUSINESS_CLASSIC]) def extract_theme_from_template(template_profile) -> ThemeConfig: """ Build a ThemeConfig from a TemplateProfile's detected colors and fonts. Adapts to dark templates by ensuring sufficient contrast for charts and text. Falls back to BUSINESS_CLASSIC if no usable colors were detected. """ detected = getattr(template_profile, 'detected_theme', {}) or {} detected_fonts = getattr(template_profile, 'detected_fonts', {}) or {} if not detected: return PRESETS[ThemePreset.BUSINESS_CLASSIC].copy() def _get(key: str, fallback: str) -> str: return detected.get(key, detected.get(key.replace('_', ''), fallback)) primary = _get('primary', '#1E3A5F') accent = _get('accent', '#10B981') accent_neg = _get('accent_neg', '#EF4444') text = _get('text', '#333333') text_gray = _get('text_gray', '#666666') bg = detected.get('background', '') # Detect if template is dark-themed is_dark = _is_dark_color(primary) or _is_dark_color(bg) or _is_dark_color(detected.get('dark', '')) if is_dark: # For dark templates: use bright, high-contrast colors primary = '#38BDF8' if _is_dark_color(primary) else primary # bright cyan-blue text = '#F8FAFC' if _is_dark_color(text) else text # near-white text_gray = '#94A3B8' if _is_dark_color(text_gray) else text_gray # light slate card_bg = '#1E293B' # dark slate card background gray_bg = '#0F172A' # very dark background line = '#334155' # medium slate line series = ['#38BDF8', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C', '#60A5FA'] else: # Light template: derive soft backgrounds from primary card_bg = _lighten_hex(primary, 0.92) gray_bg = _lighten_hex(primary, 0.96) line = _lighten_hex(text, 0.80) series = [primary, accent, accent_neg] if 'accent2' in detected: series.append(detected['accent2']) else: series.append('#ED7D31') series.extend(['#64748B', '#EF4444', '#707070', '#4472C4']) series = series[:8] return ThemeConfig( preset=ThemePreset.FROM_TEMPLATE, name='模板提取主题', primary=primary, accent=accent, accent_neg=accent_neg, secondary=_get('secondary', '#64748B'), dark=_get('dark', primary), white='#FFFFFF', gray_bg=gray_bg, card_bg=card_bg, text=text, text_gray=text_gray, line=line, chart_series=series, title_font=detected_fonts.get('title_font', '微软雅黑'), body_font=detected_fonts.get('body_font', '微软雅黑'), number_font=detected_fonts.get('number_font', 'Arial'), ) def _is_dark_color(hex_str: str) -> bool: """Check if a hex color is dark (luminance < 120).""" if not hex_str or not isinstance(hex_str, str): return False hex_str = hex_str.lstrip('#') if len(hex_str) != 6: return False try: r = int(hex_str[0:2], 16) g = int(hex_str[2:4], 16) b = int(hex_str[4:6], 16) luminance = 0.299 * r + 0.587 * g + 0.114 * b return luminance < 120 except Exception: return False def _lighten_hex(hex_str: str, factor: float) -> str: """Lighten a hex color by mixing with white. factor 0=original, 1=white.""" hex_str = hex_str.lstrip('#') if len(hex_str) != 6: return '#F2F2F2' try: r = int(int(hex_str[0:2], 16) * (1 - factor) + 255 * factor) g = int(int(hex_str[2:4], 16) * (1 - factor) + 255 * factor) b = int(int(hex_str[4:6], 16) * (1 - factor) + 255 * factor) return f'#{min(255, r):02X}{min(255, g):02X}{min(255, b):02X}' except Exception: return '#F2F2F2' def merge_theme(template_theme: ThemeConfig, user_theme: ThemeConfig) -> ThemeConfig: """Merge two ThemeConfigs, with user_theme overriding non-empty values.""" from dataclasses import fields result = ThemeConfig() for f in fields(ThemeConfig): user_val = getattr(user_theme, f.name, None) template_val = getattr(template_theme, f.name, None) if user_val is not None and user_val != '' and user_val != f.default: setattr(result, f.name, user_val) elif template_val is not None and template_val != '' and template_val != f.default: setattr(result, f.name, template_val) return result def theme_to_rgb_colors(theme: ThemeConfig) -> dict: return { 'primary': _hex_to_rgb(theme.primary), 'accent': _hex_to_rgb(theme.accent), 'accent_neg': _hex_to_rgb(theme.accent_neg), 'secondary': _hex_to_rgb(theme.secondary), 'dark': _hex_to_rgb(theme.dark), 'white': _hex_to_rgb(theme.white), 'gray_bg': _hex_to_rgb(theme.gray_bg), 'card_bg': _hex_to_rgb(theme.card_bg), 'text': _hex_to_rgb(theme.text), 'text_gray': _hex_to_rgb(theme.text_gray), 'line': _hex_to_rgb(theme.line), 'green': _hex_to_rgb(theme.accent), 'red': _hex_to_rgb(theme.accent_neg), 'orange': _hex_to_rgb(theme.chart_series[2]) if len(theme.chart_series) > 2 else RGBColor(0xED, 0x7D, 0x31), 'series': [_hex_to_rgb(c) for c in theme.chart_series], } def _hex_to_rgb(hex_str: str) -> RGBColor: hex_str = hex_str.lstrip('#') if len(hex_str) == 6: return RGBColor(int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)) return RGBColor(0x33, 0x33, 0x33) def list_themes() -> list[dict]: result = [] for preset, config in PRESETS.items(): result.append({ 'key': preset.value, 'name': config.name, 'primary': config.primary, 'accent': config.accent, }) result.append({ 'key': 'custom', 'name': '自定义主题', 'primary': '自定义', 'accent': '自定义', }) return result if __name__ == '__main__': for t in list_themes(): print(f"{t['key']}: {t['name']} (primary={t['primary']}, accent={t['accent']})")