| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- """
- 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']})")
|