theme_manager.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. """
  2. Multi-theme color and visual style manager for the universal data report generator.
  3. """
  4. from pptx.dml.color import RGBColor
  5. from report_config import ThemeConfig, ThemePreset
  6. PRESETS = {
  7. ThemePreset.BUSINESS_CLASSIC: ThemeConfig(
  8. preset=ThemePreset.BUSINESS_CLASSIC,
  9. name='商务经典',
  10. primary='#1E3A5F',
  11. accent='#10B981',
  12. accent_neg='#EF4444',
  13. secondary='#64748B',
  14. dark='#1F3A5C',
  15. white='#FFFFFF',
  16. gray_bg='#F2F2F2',
  17. card_bg='#E7F0F7',
  18. text='#333333',
  19. text_gray='#666666',
  20. line='#D9D9D9',
  21. chart_series=[
  22. '#1E3A5F', '#10B981', '#ED7D31', '#64748B',
  23. '#EF4444', '#707070', '#4472C4', '#5B9BD5',
  24. ],
  25. ),
  26. ThemePreset.FRESH_SIMPLE: ThemeConfig(
  27. preset=ThemePreset.FRESH_SIMPLE,
  28. name='清新简约',
  29. primary='#1B8A5E',
  30. accent='#10B981',
  31. accent_neg='#EF4444',
  32. secondary='#94A3B8',
  33. dark='#0F5C3B',
  34. white='#FFFFFF',
  35. gray_bg='#F8FAFC',
  36. card_bg='#ECFDF5',
  37. text='#1E293B',
  38. text_gray='#64748B',
  39. line='#E2E8F0',
  40. chart_series=[
  41. '#1B8A5E', '#3B82F6', '#F59E0B', '#94A3B8',
  42. '#EF4444', '#8B5CF6', '#06B6D4', '#10B981',
  43. ],
  44. ),
  45. ThemePreset.DARK_PROFESSIONAL: ThemeConfig(
  46. preset=ThemePreset.DARK_PROFESSIONAL,
  47. name='深色专业',
  48. primary='#1E293B',
  49. accent='#38BDF8',
  50. accent_neg='#F87171',
  51. secondary='#94A3B8',
  52. dark='#0F172A',
  53. white='#FFFFFF',
  54. gray_bg='#F1F5F9',
  55. card_bg='#E2E8F0',
  56. text='#1E293B',
  57. text_gray='#475569',
  58. line='#CBD5E1',
  59. chart_series=[
  60. '#1E293B', '#38BDF8', '#F59E0B', '#94A3B8',
  61. '#F87171', '#A78BFA', '#34D399', '#FB923C',
  62. ],
  63. ),
  64. ThemePreset.WARM_BRAND: ThemeConfig(
  65. preset=ThemePreset.WARM_BRAND,
  66. name='温暖品牌',
  67. primary='#C2410C',
  68. accent='#F97316',
  69. accent_neg='#DC2626',
  70. secondary='#78716C',
  71. dark='#7C2D12',
  72. white='#FFFFFF',
  73. gray_bg='#FFFBEB',
  74. card_bg='#FFF7ED',
  75. text='#292524',
  76. text_gray='#78716C',
  77. line='#D6D3D1',
  78. chart_series=[
  79. '#C2410C', '#F97316', '#EAB308', '#78716C',
  80. '#DC2626', '#84CC16', '#06B6D4', '#A855F7',
  81. ],
  82. ),
  83. }
  84. def get_theme(preset: ThemePreset, custom_overrides: dict = None) -> ThemeConfig:
  85. if preset == ThemePreset.CUSTOM:
  86. config = ThemeConfig(preset=ThemePreset.CUSTOM, name='自定义主题')
  87. if custom_overrides:
  88. for k, v in custom_overrides.items():
  89. if hasattr(config, k):
  90. setattr(config, k, v)
  91. return config
  92. return PRESETS.get(preset, PRESETS[ThemePreset.BUSINESS_CLASSIC])
  93. def extract_theme_from_template(template_profile) -> ThemeConfig:
  94. """
  95. Build a ThemeConfig from a TemplateProfile's detected colors and fonts.
  96. Adapts to dark templates by ensuring sufficient contrast for charts and text.
  97. Falls back to BUSINESS_CLASSIC if no usable colors were detected.
  98. """
  99. detected = getattr(template_profile, 'detected_theme', {}) or {}
  100. detected_fonts = getattr(template_profile, 'detected_fonts', {}) or {}
  101. if not detected:
  102. return PRESETS[ThemePreset.BUSINESS_CLASSIC].copy()
  103. def _get(key: str, fallback: str) -> str:
  104. return detected.get(key, detected.get(key.replace('_', ''), fallback))
  105. primary = _get('primary', '#1E3A5F')
  106. accent = _get('accent', '#10B981')
  107. accent_neg = _get('accent_neg', '#EF4444')
  108. text = _get('text', '#333333')
  109. text_gray = _get('text_gray', '#666666')
  110. bg = detected.get('background', '')
  111. # Detect if template is dark-themed
  112. is_dark = _is_dark_color(primary) or _is_dark_color(bg) or _is_dark_color(detected.get('dark', ''))
  113. if is_dark:
  114. # For dark templates: use bright, high-contrast colors
  115. primary = '#38BDF8' if _is_dark_color(primary) else primary # bright cyan-blue
  116. text = '#F8FAFC' if _is_dark_color(text) else text # near-white
  117. text_gray = '#94A3B8' if _is_dark_color(text_gray) else text_gray # light slate
  118. card_bg = '#1E293B' # dark slate card background
  119. gray_bg = '#0F172A' # very dark background
  120. line = '#334155' # medium slate line
  121. series = ['#38BDF8', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C', '#60A5FA']
  122. else:
  123. # Light template: derive soft backgrounds from primary
  124. card_bg = _lighten_hex(primary, 0.92)
  125. gray_bg = _lighten_hex(primary, 0.96)
  126. line = _lighten_hex(text, 0.80)
  127. series = [primary, accent, accent_neg]
  128. if 'accent2' in detected:
  129. series.append(detected['accent2'])
  130. else:
  131. series.append('#ED7D31')
  132. series.extend(['#64748B', '#EF4444', '#707070', '#4472C4'])
  133. series = series[:8]
  134. return ThemeConfig(
  135. preset=ThemePreset.FROM_TEMPLATE,
  136. name='模板提取主题',
  137. primary=primary,
  138. accent=accent,
  139. accent_neg=accent_neg,
  140. secondary=_get('secondary', '#64748B'),
  141. dark=_get('dark', primary),
  142. white='#FFFFFF',
  143. gray_bg=gray_bg,
  144. card_bg=card_bg,
  145. text=text,
  146. text_gray=text_gray,
  147. line=line,
  148. chart_series=series,
  149. title_font=detected_fonts.get('title_font', '微软雅黑'),
  150. body_font=detected_fonts.get('body_font', '微软雅黑'),
  151. number_font=detected_fonts.get('number_font', 'Arial'),
  152. )
  153. def _is_dark_color(hex_str: str) -> bool:
  154. """Check if a hex color is dark (luminance < 120)."""
  155. if not hex_str or not isinstance(hex_str, str):
  156. return False
  157. hex_str = hex_str.lstrip('#')
  158. if len(hex_str) != 6:
  159. return False
  160. try:
  161. r = int(hex_str[0:2], 16)
  162. g = int(hex_str[2:4], 16)
  163. b = int(hex_str[4:6], 16)
  164. luminance = 0.299 * r + 0.587 * g + 0.114 * b
  165. return luminance < 120
  166. except Exception:
  167. return False
  168. def _lighten_hex(hex_str: str, factor: float) -> str:
  169. """Lighten a hex color by mixing with white. factor 0=original, 1=white."""
  170. hex_str = hex_str.lstrip('#')
  171. if len(hex_str) != 6:
  172. return '#F2F2F2'
  173. try:
  174. r = int(int(hex_str[0:2], 16) * (1 - factor) + 255 * factor)
  175. g = int(int(hex_str[2:4], 16) * (1 - factor) + 255 * factor)
  176. b = int(int(hex_str[4:6], 16) * (1 - factor) + 255 * factor)
  177. return f'#{min(255, r):02X}{min(255, g):02X}{min(255, b):02X}'
  178. except Exception:
  179. return '#F2F2F2'
  180. def merge_theme(template_theme: ThemeConfig, user_theme: ThemeConfig) -> ThemeConfig:
  181. """Merge two ThemeConfigs, with user_theme overriding non-empty values."""
  182. from dataclasses import fields
  183. result = ThemeConfig()
  184. for f in fields(ThemeConfig):
  185. user_val = getattr(user_theme, f.name, None)
  186. template_val = getattr(template_theme, f.name, None)
  187. if user_val is not None and user_val != '' and user_val != f.default:
  188. setattr(result, f.name, user_val)
  189. elif template_val is not None and template_val != '' and template_val != f.default:
  190. setattr(result, f.name, template_val)
  191. return result
  192. def theme_to_rgb_colors(theme: ThemeConfig) -> dict:
  193. return {
  194. 'primary': _hex_to_rgb(theme.primary),
  195. 'accent': _hex_to_rgb(theme.accent),
  196. 'accent_neg': _hex_to_rgb(theme.accent_neg),
  197. 'secondary': _hex_to_rgb(theme.secondary),
  198. 'dark': _hex_to_rgb(theme.dark),
  199. 'white': _hex_to_rgb(theme.white),
  200. 'gray_bg': _hex_to_rgb(theme.gray_bg),
  201. 'card_bg': _hex_to_rgb(theme.card_bg),
  202. 'text': _hex_to_rgb(theme.text),
  203. 'text_gray': _hex_to_rgb(theme.text_gray),
  204. 'line': _hex_to_rgb(theme.line),
  205. 'green': _hex_to_rgb(theme.accent),
  206. 'red': _hex_to_rgb(theme.accent_neg),
  207. 'orange': _hex_to_rgb(theme.chart_series[2]) if len(theme.chart_series) > 2 else RGBColor(0xED, 0x7D, 0x31),
  208. 'series': [_hex_to_rgb(c) for c in theme.chart_series],
  209. }
  210. def _hex_to_rgb(hex_str: str) -> RGBColor:
  211. hex_str = hex_str.lstrip('#')
  212. if len(hex_str) == 6:
  213. return RGBColor(int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
  214. return RGBColor(0x33, 0x33, 0x33)
  215. def list_themes() -> list[dict]:
  216. result = []
  217. for preset, config in PRESETS.items():
  218. result.append({
  219. 'key': preset.value,
  220. 'name': config.name,
  221. 'primary': config.primary,
  222. 'accent': config.accent,
  223. })
  224. result.append({
  225. 'key': 'custom',
  226. 'name': '自定义主题',
  227. 'primary': '自定义',
  228. 'accent': '自定义',
  229. })
  230. return result
  231. if __name__ == '__main__':
  232. for t in list_themes():
  233. print(f"{t['key']}: {t['name']} (primary={t['primary']}, accent={t['accent']})")