Initial commit
This commit is contained in:
632
skills/xlsx/templates/base.py
Executable file
632
skills/xlsx/templates/base.py
Executable file
@@ -0,0 +1,632 @@
|
||||
"""
|
||||
xlsx skill — Base Template
|
||||
===========================
|
||||
Single source of truth for design tokens, font resolution, and style factories.
|
||||
All scene/engine code MUST import from here. Never hardcode colors, fonts, or styles.
|
||||
|
||||
Usage:
|
||||
from templates.base import *
|
||||
|
||||
# To switch palette based on user prompt (call BEFORE creating styles):
|
||||
use_palette("帮我做一个温暖的销售月报") # Chinese prompt example
|
||||
# → All color tokens and style factories now use 'warm' palette.
|
||||
|
||||
# Or manually:
|
||||
use_palette_explicit("warm")
|
||||
"""
|
||||
|
||||
import platform
|
||||
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
|
||||
from copy import copy
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §1 Font Resolution (cross-platform fallback chain)
|
||||
# ============================================================
|
||||
|
||||
def _resolve_font(candidates: list) -> str:
|
||||
"""Return the first font name likely available on this OS."""
|
||||
system = platform.system()
|
||||
_platform_hints = {
|
||||
"Darwin": {"PingFang SC", "Hiragino Sans GB", ".AppleSystemUIFont"},
|
||||
"Windows": {"Microsoft YaHei", "SimHei", "SimSun"},
|
||||
"Linux": {"Noto Sans CJK SC", "WenQuanYi Micro Hei", "Source Han Sans SC"},
|
||||
}
|
||||
available = _platform_hints.get(system, set())
|
||||
for name in candidates:
|
||||
if name in available:
|
||||
return name
|
||||
return candidates[0]
|
||||
|
||||
|
||||
# CJK sans-serif fallback chain
|
||||
CJK_BODY_CHAIN = [
|
||||
"PingFang SC", # macOS
|
||||
"Microsoft YaHei", # Windows
|
||||
"Noto Sans CJK SC", # Linux / Android
|
||||
"Hiragino Sans GB", # macOS alt
|
||||
"Source Han Sans SC", # Adobe cross-platform
|
||||
"SimHei", # classic fallback
|
||||
]
|
||||
|
||||
# Latin serif (for formal reports)
|
||||
LATIN_BODY_CHAIN = [
|
||||
"Times New Roman",
|
||||
"Georgia",
|
||||
"serif",
|
||||
]
|
||||
|
||||
FONT_CJK = _resolve_font(CJK_BODY_CHAIN)
|
||||
FONT_LATIN = _resolve_font(LATIN_BODY_CHAIN)
|
||||
|
||||
# Primary font — CJK font covers ASCII too
|
||||
FONT_NAME = FONT_CJK
|
||||
|
||||
# Bold strategy: heavy-stroke fonts should NOT be bolded
|
||||
_HEAVY_FONTS = {
|
||||
"SimHei", "Microsoft YaHei", "PingFang SC",
|
||||
"Noto Sans CJK SC", "Source Han Sans SC",
|
||||
"Hiragino Sans GB", "WenQuanYi Micro Hei",
|
||||
}
|
||||
HEADER_BOLD = FONT_NAME not in _HEAVY_FONTS
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §2 Color Tokens (Three-Color Rule)
|
||||
# ============================================================
|
||||
|
||||
# --- Primary (deep blue — professional default) ---
|
||||
PRIMARY = "1B2A4A"
|
||||
PRIMARY_LIGHT = "D6E4F0"
|
||||
SECONDARY = PRIMARY_LIGHT # derived from primary
|
||||
|
||||
# --- Accent (semantic, on-demand) ---
|
||||
ACCENT_POSITIVE = "1B7D46" # growth, done, pass (deep green)
|
||||
ACCENT_NEGATIVE = "C0392B" # decline, overdue (deep red)
|
||||
ACCENT_WARNING = "D4820A" # at-risk, watch (deep amber)
|
||||
|
||||
# --- Neutral (warm gray) ---
|
||||
NEUTRAL_900 = "37352F" # body text
|
||||
NEUTRAL_600 = "8C8A84" # caption, secondary text
|
||||
NEUTRAL_200 = "E9E9E8" # borders, dividers
|
||||
NEUTRAL_100 = "F7F7F5" # alternating row fill (odd)
|
||||
NEUTRAL_50 = "FAFAF9" # ultra-light bg (optional)
|
||||
NEUTRAL_0 = "FFFFFF" # white (even rows)
|
||||
|
||||
# --- Header text color (overridable by palette) ---
|
||||
HEADER_TEXT = "FFFFFF"
|
||||
|
||||
# --- Chart palette (max 5 colors) ---
|
||||
CHART_COLORS = [PRIMARY, ACCENT_POSITIVE, ACCENT_WARNING, ACCENT_NEGATIVE, NEUTRAL_600]
|
||||
|
||||
# --- Conditional formatting fills ---
|
||||
CF_POSITIVE_FILL = PatternFill(bgColor="E8F5E9")
|
||||
CF_POSITIVE_FONT = Font(color=ACCENT_POSITIVE)
|
||||
CF_NEGATIVE_FILL = PatternFill(bgColor="FDEDEC")
|
||||
CF_NEGATIVE_FONT = Font(color=ACCENT_NEGATIVE)
|
||||
CF_WARNING_FILL = PatternFill(bgColor="FEF9E7")
|
||||
CF_WARNING_FONT = Font(color=ACCENT_WARNING)
|
||||
|
||||
# --- Active style (for debugging/logging) ---
|
||||
_ACTIVE_STYLE = "professional"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §2.1 Palette Integration
|
||||
# ============================================================
|
||||
|
||||
def use_palette(prompt: str):
|
||||
"""
|
||||
Auto-detect style from user prompt and switch all color tokens.
|
||||
Call this BEFORE creating any styles/cells.
|
||||
|
||||
Three-step matching:
|
||||
1. Explicit style keywords → direct match
|
||||
2. Scene/content keywords → infer style
|
||||
3. No match → professional (safe default)
|
||||
|
||||
Example:
|
||||
use_palette("帮我做一个温暖的销售月报") # Chinese prompt example
|
||||
# → 'warm' palette applied
|
||||
"""
|
||||
from templates.palettes import resolve_palette_with_info
|
||||
palette, style = resolve_palette_with_info(prompt)
|
||||
_apply(palette, style)
|
||||
|
||||
|
||||
def use_palette_explicit(style: str = "professional"):
|
||||
"""
|
||||
Manually select a palette by style name.
|
||||
Available: professional, warm, elegant, creative, muji, aesop,
|
||||
kinfolk, celine, bottega, chanel, bloomberg, original_blue
|
||||
|
||||
Example:
|
||||
use_palette_explicit("warm")
|
||||
"""
|
||||
from templates.palettes import get_palette
|
||||
palette = get_palette(style)
|
||||
_apply(palette, style)
|
||||
|
||||
|
||||
def get_active_style() -> str:
|
||||
"""Return the currently active style name."""
|
||||
return _ACTIVE_STYLE
|
||||
|
||||
|
||||
def _apply(palette: dict, style: str):
|
||||
"""Internal: apply a palette dict to all module-level color tokens."""
|
||||
global PRIMARY, PRIMARY_LIGHT, SECONDARY
|
||||
global ACCENT_POSITIVE, ACCENT_NEGATIVE, ACCENT_WARNING
|
||||
global NEUTRAL_900, NEUTRAL_600, NEUTRAL_200, NEUTRAL_100, NEUTRAL_50, NEUTRAL_0
|
||||
global CHART_COLORS, HEADER_TEXT
|
||||
global CF_POSITIVE_FILL, CF_POSITIVE_FONT
|
||||
global CF_NEGATIVE_FILL, CF_NEGATIVE_FONT
|
||||
global CF_WARNING_FILL, CF_WARNING_FONT
|
||||
global _ACTIVE_STYLE
|
||||
|
||||
PRIMARY = palette["PRIMARY"]
|
||||
PRIMARY_LIGHT = palette["PRIMARY_LIGHT"]
|
||||
SECONDARY = palette["SECONDARY"]
|
||||
ACCENT_POSITIVE = palette["ACCENT_POSITIVE"]
|
||||
ACCENT_NEGATIVE = palette["ACCENT_NEGATIVE"]
|
||||
ACCENT_WARNING = palette["ACCENT_WARNING"]
|
||||
NEUTRAL_900 = palette["NEUTRAL_900"]
|
||||
NEUTRAL_600 = palette["NEUTRAL_600"]
|
||||
NEUTRAL_200 = palette["NEUTRAL_200"]
|
||||
NEUTRAL_100 = palette["NEUTRAL_100"]
|
||||
NEUTRAL_50 = palette["NEUTRAL_50"]
|
||||
NEUTRAL_0 = palette["NEUTRAL_0"]
|
||||
HEADER_TEXT = palette.get("HEADER_TEXT", "FFFFFF")
|
||||
CHART_COLORS = palette["CHART_COLORS"]
|
||||
|
||||
# Rebuild conditional formatting fills/fonts with new accent colors
|
||||
CF_POSITIVE_FILL = PatternFill(bgColor=palette.get("CF_POSITIVE_BG", "E8F5E9"))
|
||||
CF_POSITIVE_FONT = Font(color=ACCENT_POSITIVE)
|
||||
CF_NEGATIVE_FILL = PatternFill(bgColor=palette.get("CF_NEGATIVE_BG", "FDEDEC"))
|
||||
CF_NEGATIVE_FONT = Font(color=ACCENT_NEGATIVE)
|
||||
CF_WARNING_FILL = PatternFill(bgColor=palette.get("CF_WARNING_BG", "FEF9E7"))
|
||||
CF_WARNING_FONT = Font(color=ACCENT_WARNING)
|
||||
|
||||
_ACTIVE_STYLE = style
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §3 Column Width Map
|
||||
# ============================================================
|
||||
|
||||
COLUMN_WIDTHS = {
|
||||
"margin": 3, # A col whitespace
|
||||
"id_short": 8, # #, ID
|
||||
"name_cn": 16, # Chinese name (2-4 chars)
|
||||
"name_en": 22, # English name
|
||||
"description": 32, # long text
|
||||
"number": 14, # currency, amount
|
||||
"percentage": 12, # %
|
||||
"date": 14, # YYYY-MM-DD
|
||||
"status": 12, # short label
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §4 Number Formats
|
||||
# ============================================================
|
||||
|
||||
FORMATS = {
|
||||
"integer": "#,##0",
|
||||
"decimal_1": "#,##0.0",
|
||||
"decimal_2": "#,##0.00",
|
||||
"percentage": "0.0%",
|
||||
"currency_cny": "¥#,##0.00",
|
||||
"currency_usd": "$#,##0.00",
|
||||
"date": "YYYY-MM-DD",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §5 Style Factories
|
||||
# ============================================================
|
||||
|
||||
def font_title():
|
||||
"""16pt title font — left-aligned, no fill."""
|
||||
return Font(name=FONT_NAME, size=16, bold=HEADER_BOLD, color=PRIMARY)
|
||||
|
||||
def font_header():
|
||||
"""11pt header font — text color on primary background."""
|
||||
return Font(name=FONT_NAME, size=11, bold=HEADER_BOLD, color=HEADER_TEXT)
|
||||
|
||||
def font_subheader():
|
||||
"""11pt sub-header — primary color text."""
|
||||
return Font(name=FONT_NAME, size=11, bold=HEADER_BOLD, color=PRIMARY)
|
||||
|
||||
def font_body():
|
||||
"""11pt body text."""
|
||||
return Font(name=FONT_NAME, size=11, color=NEUTRAL_900)
|
||||
|
||||
def font_caption():
|
||||
"""9pt caption / footnote."""
|
||||
return Font(name=FONT_NAME, size=9, color=NEUTRAL_600)
|
||||
|
||||
def font_kpi():
|
||||
"""22pt big KPI number."""
|
||||
return Font(name=FONT_NAME, size=22, bold=HEADER_BOLD, color=PRIMARY)
|
||||
|
||||
def font_kpi_label():
|
||||
"""9pt KPI label."""
|
||||
return Font(name=FONT_NAME, size=9, color=NEUTRAL_600)
|
||||
|
||||
|
||||
def make_chart_title(text, size_pt=12, bold=True, axis=False, max_line_chars=6):
|
||||
"""
|
||||
Build a chart Title with font baked into <tx><rich><defRPr>/<rPr>.
|
||||
Ensures WPS and Office render identical font name and size.
|
||||
Uses FONT_NAME and HEADER_BOLD from §1 — no hardcoded font names.
|
||||
|
||||
Args:
|
||||
axis: If True, set bodyPr rot=-5400000 (rotate -90°) for Y-axis titles.
|
||||
max_line_chars: For axis titles, auto-insert line breaks (\n) when text
|
||||
exceeds this length. Breaks at parentheses boundaries.
|
||||
The text stays in ONE run inside ONE paragraph — this prevents
|
||||
WPS/Office from creating separate overlapping text boxes.
|
||||
Set to 0 or None to disable.
|
||||
|
||||
Key insight: Multiple <p> paragraphs in axis titles cause WPS to render
|
||||
them as stacked overlapping text boxes. Instead, we use a SINGLE <r> run
|
||||
with \\n line breaks inside the text, which both Office and WPS render
|
||||
as line breaks within the same text box.
|
||||
"""
|
||||
from openpyxl.chart.title import Title
|
||||
from openpyxl.chart.text import Text, RichText
|
||||
from openpyxl.drawing.text import (
|
||||
Paragraph, ParagraphProperties, CharacterProperties,
|
||||
Font as DrawingFont, RichTextProperties, RegularTextRun,
|
||||
LineBreak,
|
||||
)
|
||||
from copy import deepcopy
|
||||
import re
|
||||
|
||||
rpr = CharacterProperties(
|
||||
latin=DrawingFont(typeface=FONT_NAME),
|
||||
ea=DrawingFont(typeface=FONT_NAME),
|
||||
sz=int(size_pt * 100),
|
||||
b=bold if HEADER_BOLD else False,
|
||||
)
|
||||
|
||||
def _insert_breaks(text, max_chars):
|
||||
"""Insert \\n before parentheses when text exceeds max_chars."""
|
||||
if not max_chars or len(text) <= max_chars:
|
||||
return text
|
||||
# Insert \n before '(' or '('
|
||||
result = re.sub(r'(?=[((])', '\n', text, count=1)
|
||||
return result
|
||||
|
||||
# For axis titles, insert line breaks to prevent overlap
|
||||
display_text = text
|
||||
if axis and max_line_chars:
|
||||
display_text = _insert_breaks(text, max_line_chars)
|
||||
|
||||
# Single paragraph, single run with \n inside the text.
|
||||
# Both Office and WPS render \n as line breaks within one text box.
|
||||
# Do NOT use multiple <p> paragraphs — WPS renders them as separate
|
||||
# overlapping text boxes on axis titles.
|
||||
run = RegularTextRun(rPr=deepcopy(rpr), t=display_text)
|
||||
|
||||
inner_body = RichTextProperties(rot=-5400000) if axis else RichTextProperties()
|
||||
para = Paragraph(
|
||||
pPr=ParagraphProperties(defRPr=deepcopy(rpr)),
|
||||
r=[run],
|
||||
)
|
||||
rich = RichText(bodyPr=inner_body, p=[para])
|
||||
|
||||
# Outer txPr: Office reads rotation from here for axis titles
|
||||
if axis:
|
||||
outer_body = RichTextProperties(rot=-5400000)
|
||||
txPr = RichText(
|
||||
bodyPr=outer_body,
|
||||
p=[Paragraph(pPr=ParagraphProperties(defRPr=deepcopy(rpr)))],
|
||||
)
|
||||
return Title(tx=Text(rich=rich), txPr=txPr)
|
||||
return Title(tx=Text(rich=rich))
|
||||
|
||||
|
||||
def fill_header():
|
||||
return PatternFill("solid", fgColor=PRIMARY)
|
||||
|
||||
def fill_total():
|
||||
return PatternFill("solid", fgColor=SECONDARY)
|
||||
|
||||
def fill_data_row(row_index: int):
|
||||
"""Alternating row: even=white, odd=warm-white."""
|
||||
color = NEUTRAL_0 if row_index % 2 == 0 else NEUTRAL_100
|
||||
return PatternFill("solid", fgColor=color)
|
||||
|
||||
|
||||
def border_header():
|
||||
"""Thin bottom border under header row."""
|
||||
return Border(bottom=Side(style="thin", color=NEUTRAL_200))
|
||||
|
||||
def border_total():
|
||||
"""Medium top border above totals row."""
|
||||
return Border(top=Side(style="medium", color=NEUTRAL_200))
|
||||
|
||||
|
||||
def align_title():
|
||||
return Alignment(horizontal="left", vertical="center")
|
||||
|
||||
def align_header():
|
||||
return Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
def align_number():
|
||||
return Alignment(horizontal="right", vertical="center")
|
||||
|
||||
def align_text():
|
||||
return Alignment(horizontal="left", vertical="center")
|
||||
|
||||
def align_date():
|
||||
return Alignment(horizontal="center", vertical="center")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §6 Sheet Setup Helpers
|
||||
# ============================================================
|
||||
|
||||
ROW_HEIGHTS = {
|
||||
"margin": 15, # row 1 top whitespace
|
||||
"title": 32, # row 2
|
||||
"spacer": 8, # row 3
|
||||
"header": 28, # row 4
|
||||
"data": 22, # data rows
|
||||
"total": 26, # totals row
|
||||
}
|
||||
|
||||
|
||||
def setup_sheet(ws, title: str = None, last_col: int = None):
|
||||
"""
|
||||
Apply standard sheet setup:
|
||||
- hide grid lines
|
||||
- set margin column A width
|
||||
- set row 1/2/3 heights
|
||||
- optionally write & style title at B2
|
||||
"""
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws.column_dimensions["A"].width = COLUMN_WIDTHS["margin"]
|
||||
ws.row_dimensions[1].height = ROW_HEIGHTS["margin"]
|
||||
ws.row_dimensions[2].height = ROW_HEIGHTS["title"]
|
||||
ws.row_dimensions[3].height = ROW_HEIGHTS["spacer"]
|
||||
|
||||
if title and last_col:
|
||||
ws.merge_cells(start_row=2, start_column=2, end_row=2, end_column=last_col)
|
||||
cell = ws.cell(row=2, column=2, value=title)
|
||||
cell.font = font_title()
|
||||
cell.alignment = align_title()
|
||||
|
||||
|
||||
def style_header_row(ws, row_num: int, col_start: int, col_end: int):
|
||||
"""Apply header style to a row range."""
|
||||
for col in range(col_start, col_end + 1):
|
||||
cell = ws.cell(row=row_num, column=col)
|
||||
cell.fill = fill_header()
|
||||
cell.font = font_header()
|
||||
cell.alignment = align_header()
|
||||
cell.border = border_header()
|
||||
ws.row_dimensions[row_num].height = ROW_HEIGHTS["header"]
|
||||
|
||||
|
||||
def style_data_row(ws, row_num: int, col_start: int, col_end: int, row_index: int):
|
||||
"""Apply data row style (alternating fill)."""
|
||||
fill = fill_data_row(row_index)
|
||||
for col in range(col_start, col_end + 1):
|
||||
cell = ws.cell(row=row_num, column=col)
|
||||
cell.fill = fill
|
||||
cell.font = font_body()
|
||||
ws.row_dimensions[row_num].height = ROW_HEIGHTS["data"]
|
||||
|
||||
|
||||
def style_total_row(ws, row_num: int, col_start: int, col_end: int):
|
||||
"""Apply totals row style."""
|
||||
for col in range(col_start, col_end + 1):
|
||||
cell = ws.cell(row=row_num, column=col)
|
||||
cell.fill = fill_total()
|
||||
cell.font = font_subheader()
|
||||
cell.border = border_total()
|
||||
ws.row_dimensions[row_num].height = ROW_HEIGHTS["total"]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §6.1 Chart Factory Functions
|
||||
# ============================================================
|
||||
|
||||
def create_bar_chart(chart_type="col", grouping="clustered", gap_width=80,
|
||||
overlap=100, style=10, width=18, height=10, **kwargs):
|
||||
"""
|
||||
Create a BarChart with sane defaults that prevent the "thin bar" / offset bug.
|
||||
|
||||
Key fixes baked in:
|
||||
- gapWidth=80 (default 150 → bars too thin)
|
||||
- overlap=100 (bars fill their slot, no empty gap for line series)
|
||||
|
||||
Returns an openpyxl BarChart ready for add_data / set_categories.
|
||||
"""
|
||||
from openpyxl.chart import BarChart
|
||||
chart = BarChart()
|
||||
chart.type = chart_type
|
||||
chart.grouping = grouping
|
||||
chart.gapWidth = gap_width
|
||||
chart.overlap = overlap
|
||||
chart.style = style
|
||||
chart.width = width
|
||||
chart.height = height
|
||||
return chart
|
||||
|
||||
|
||||
def create_line_chart(style=10, width=18, height=11, **kwargs):
|
||||
"""Create a LineChart with standard defaults."""
|
||||
from openpyxl.chart import LineChart
|
||||
chart = LineChart()
|
||||
chart.style = style
|
||||
chart.width = width
|
||||
chart.height = height
|
||||
return chart
|
||||
|
||||
|
||||
def create_pie_chart(style=10, width=14, height=10, **kwargs):
|
||||
"""Create a PieChart with standard defaults."""
|
||||
from openpyxl.chart import PieChart
|
||||
chart = PieChart()
|
||||
chart.style = style
|
||||
chart.width = width
|
||||
chart.height = height
|
||||
return chart
|
||||
|
||||
|
||||
def setup_chart_titles(chart, title=None, y_title=None, x_title=None,
|
||||
title_size=12, axis_size=10):
|
||||
"""
|
||||
Set chart title and axis titles using make_chart_title() for
|
||||
cross-platform font consistency (Office + WPS).
|
||||
|
||||
This is the ONLY correct way to set chart titles. Never do:
|
||||
chart.title = "some string" # ← WRONG
|
||||
chart.y_axis.title = "some string" # ← WRONG
|
||||
|
||||
Args:
|
||||
chart: openpyxl chart object
|
||||
title: main chart title (optional)
|
||||
y_title: Y-axis title (optional, auto-rotated -90°)
|
||||
x_title: X-axis title (optional)
|
||||
title_size: font size for main title (default 12)
|
||||
axis_size: font size for axis titles (default 10)
|
||||
"""
|
||||
if title is not None:
|
||||
chart.title = make_chart_title(title, size_pt=title_size, bold=True)
|
||||
if y_title is not None:
|
||||
chart.y_axis.title = make_chart_title(y_title, size_pt=axis_size, bold=False, axis=True)
|
||||
if x_title is not None:
|
||||
chart.x_axis.title = make_chart_title(x_title, size_pt=axis_size, bold=False)
|
||||
|
||||
|
||||
def apply_chart_colors(chart, colors=None):
|
||||
"""
|
||||
Apply palette colors to all series in a chart.
|
||||
Call AFTER add_data().
|
||||
|
||||
Args:
|
||||
chart: openpyxl chart object (BarChart, LineChart, etc.)
|
||||
colors: list of hex color strings (default: CHART_COLORS)
|
||||
"""
|
||||
if colors is None:
|
||||
colors = CHART_COLORS
|
||||
for i, series in enumerate(chart.series):
|
||||
color_hex = colors[i % len(colors)]
|
||||
series.graphicalProperties.solidFill = color_hex
|
||||
# For line charts, also set line color
|
||||
if hasattr(series.graphicalProperties, 'line') and series.graphicalProperties.line is not None:
|
||||
series.graphicalProperties.line.solidFill = color_hex
|
||||
|
||||
|
||||
def apply_pie_colors(chart, count, colors=None):
|
||||
"""
|
||||
Apply palette colors to pie chart data points.
|
||||
Call AFTER add_data().
|
||||
|
||||
Args:
|
||||
chart: openpyxl PieChart
|
||||
count: number of data points (slices)
|
||||
colors: list of hex color strings (default: CHART_COLORS)
|
||||
"""
|
||||
from openpyxl.chart.series import DataPoint
|
||||
if colors is None:
|
||||
colors = CHART_COLORS
|
||||
for idx in range(count):
|
||||
pt = DataPoint(idx=idx)
|
||||
pt.graphicalProperties.solidFill = colors[idx % len(colors)]
|
||||
chart.series[0].data_points.append(pt)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §7 Utility Functions
|
||||
# ============================================================
|
||||
|
||||
def normalize_cell_value(value):
|
||||
"""Normalize cell values: convert invisible whitespace variants to None."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip().replace("\xa0", "").replace("\u200b", "")
|
||||
if stripped == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def copy_style(source_cell, target_cell):
|
||||
"""Copy all styling from source to target cell."""
|
||||
target_cell.font = copy(source_cell.font)
|
||||
target_cell.fill = copy(source_cell.fill)
|
||||
target_cell.border = copy(source_cell.border)
|
||||
target_cell.alignment = copy(source_cell.alignment)
|
||||
target_cell.number_format = source_cell.number_format
|
||||
|
||||
|
||||
def auto_fit_columns(ws, min_width=8, max_width=28, header_row=None, data_start_row=None):
|
||||
"""
|
||||
Auto-fit column widths based on DATA content (not header).
|
||||
Headers that exceed the computed width get wrap_text=True instead of stretching the column.
|
||||
|
||||
Args:
|
||||
ws: worksheet
|
||||
min_width: minimum column width (default 8)
|
||||
max_width: maximum column width (default 28)
|
||||
header_row: row number of the header (auto-detected if None)
|
||||
data_start_row: first data row (auto-detected as header_row + 1 if None)
|
||||
"""
|
||||
import unicodedata
|
||||
|
||||
def _display_width(text):
|
||||
"""Estimate display width: CJK chars count as ~1.7, others as 1."""
|
||||
if text is None:
|
||||
return 0
|
||||
s = str(text)
|
||||
w = 0
|
||||
for ch in s:
|
||||
if unicodedata.east_asian_width(ch) in ('W', 'F'):
|
||||
w += 1.7
|
||||
else:
|
||||
w += 1
|
||||
return w
|
||||
|
||||
# Auto-detect header row: first row with data starting from column B
|
||||
if header_row is None:
|
||||
for row in range(1, ws.max_row + 1):
|
||||
val = ws.cell(row=row, column=2).value
|
||||
if val is not None:
|
||||
header_row = row
|
||||
break
|
||||
if header_row is None:
|
||||
return
|
||||
|
||||
if data_start_row is None:
|
||||
data_start_row = header_row + 1
|
||||
|
||||
for col_cells in ws.iter_cols(min_col=1, max_col=ws.max_column, min_row=data_start_row, max_row=ws.max_row):
|
||||
if not col_cells:
|
||||
continue
|
||||
col_letter = col_cells[0].column_letter
|
||||
|
||||
# Skip margin column A
|
||||
if col_letter == 'A':
|
||||
continue
|
||||
|
||||
# Width based on data content only
|
||||
max_data_w = max((_display_width(c.value) for c in col_cells), default=0)
|
||||
width = min(max_width, max(min_width, max_data_w + 2))
|
||||
ws.column_dimensions[col_letter].width = width
|
||||
|
||||
# If header text is wider than computed column width, wrap it
|
||||
header_cell = ws.cell(row=header_row, column=col_cells[0].column)
|
||||
header_w = _display_width(header_cell.value)
|
||||
if header_w > width:
|
||||
current_align = header_cell.alignment
|
||||
header_cell.alignment = Alignment(
|
||||
horizontal=current_align.horizontal or "center",
|
||||
vertical=current_align.vertical or "center",
|
||||
wrap_text=True,
|
||||
)
|
||||
521
skills/xlsx/templates/palettes.py
Executable file
521
skills/xlsx/templates/palettes.py
Executable file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
xlsx skill — Palette System (Style-First Theme Engine)
|
||||
=======================================================
|
||||
12 visual styles × scene-based fallback. No domain-color binding.
|
||||
|
||||
Themes (12):
|
||||
professional, warm, elegant, creative,
|
||||
muji, aesop, kinfolk, celine, bottega, chanel, bloomberg, original_blue
|
||||
|
||||
Matching priority:
|
||||
1. Explicit style keywords in prompt → direct match
|
||||
2. Scene/content keywords → infer style
|
||||
3. No match → professional (safe default)
|
||||
|
||||
Usage:
|
||||
from templates.palettes import resolve_palette, get_palette
|
||||
|
||||
# Auto-detect from user prompt
|
||||
palette = resolve_palette("帮我做一个温暖的销售月报") # Chinese prompt example
|
||||
# → warm palette
|
||||
|
||||
# Manual selection
|
||||
palette = get_palette("bottega")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
# ============================================================
|
||||
# §1 Palette Data Structure
|
||||
# ============================================================
|
||||
|
||||
_Palette = Dict[str, str | list]
|
||||
|
||||
|
||||
def _make_palette(
|
||||
*,
|
||||
primary: str,
|
||||
primary_light: str,
|
||||
accent_positive: str = "1B7D46",
|
||||
accent_negative: str = "C0392B",
|
||||
accent_warning: str = "D4820A",
|
||||
neutral_900: str = "37352F",
|
||||
neutral_600: str = "8C8A84",
|
||||
neutral_200: str = "E9E9E8",
|
||||
neutral_100: str = "F7F7F5",
|
||||
neutral_50: str = "FAFAF9",
|
||||
neutral_0: str = "FFFFFF",
|
||||
header_text: str = "FFFFFF",
|
||||
cf_positive_bg: str = "E8F5E9",
|
||||
cf_negative_bg: str = "FDEDEC",
|
||||
cf_warning_bg: str = "FEF9E7",
|
||||
) -> _Palette:
|
||||
return {
|
||||
"PRIMARY": primary,
|
||||
"PRIMARY_LIGHT": primary_light,
|
||||
"SECONDARY": primary_light,
|
||||
"ACCENT_POSITIVE": accent_positive,
|
||||
"ACCENT_NEGATIVE": accent_negative,
|
||||
"ACCENT_WARNING": accent_warning,
|
||||
"NEUTRAL_900": neutral_900,
|
||||
"NEUTRAL_600": neutral_600,
|
||||
"NEUTRAL_200": neutral_200,
|
||||
"NEUTRAL_100": neutral_100,
|
||||
"NEUTRAL_50": neutral_50,
|
||||
"NEUTRAL_0": neutral_0,
|
||||
"HEADER_TEXT": header_text,
|
||||
"CF_POSITIVE_BG": cf_positive_bg,
|
||||
"CF_NEGATIVE_BG": cf_negative_bg,
|
||||
"CF_WARNING_BG": cf_warning_bg,
|
||||
"CHART_COLORS": [primary, accent_positive, accent_warning, accent_negative, neutral_600],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §2 Legacy Palettes (6 original styles)
|
||||
# ============================================================
|
||||
|
||||
# -- Professional: formal business, universal default --
|
||||
PROFESSIONAL = _make_palette(
|
||||
primary="1B2A4A",
|
||||
primary_light="D6E4F0",
|
||||
)
|
||||
|
||||
# -- Warm: warm and vibrant, high impact --
|
||||
WARM = _make_palette(
|
||||
primary="B85C1E",
|
||||
primary_light="F5E6D5",
|
||||
accent_positive="2E7D32",
|
||||
accent_negative="C62828",
|
||||
accent_warning="E65100",
|
||||
neutral_900="3E2F1F",
|
||||
neutral_600="9C8B78",
|
||||
neutral_200="EAE0D5",
|
||||
neutral_100="F7F2EC",
|
||||
neutral_50="FBF8F5",
|
||||
)
|
||||
|
||||
# -- Fresh: natural freshness, friendly and light --
|
||||
FRESH = _make_palette(
|
||||
primary="0E7C6B",
|
||||
primary_light="D4F0EB",
|
||||
accent_positive="2E9E5A",
|
||||
accent_negative="D94F4F",
|
||||
accent_warning="E6A023",
|
||||
neutral_900="2F3735",
|
||||
neutral_600="7A8C87",
|
||||
neutral_200="DEE9E6",
|
||||
neutral_100="F2F8F6",
|
||||
neutral_50="F8FBFA",
|
||||
)
|
||||
|
||||
# -- Elegant: premium restraint, minimalist black-white --
|
||||
ELEGANT = _make_palette(
|
||||
primary="2C2C2C",
|
||||
primary_light="E5E5E5",
|
||||
accent_positive="4A4A4A",
|
||||
accent_negative="8B0000",
|
||||
accent_warning="6B6B6B",
|
||||
neutral_900="1A1A1A",
|
||||
neutral_600="808080",
|
||||
neutral_200="D4D4D4",
|
||||
neutral_100="F0F0F0",
|
||||
neutral_50="F8F8F8",
|
||||
)
|
||||
|
||||
# -- Creative: artistic personality, distinctive --
|
||||
CREATIVE = _make_palette(
|
||||
primary="6C5B7B",
|
||||
primary_light="E4DDE8",
|
||||
accent_positive="6B9E78",
|
||||
accent_negative="C06C7E",
|
||||
accent_warning="C4A46A",
|
||||
neutral_900="3E3A42",
|
||||
neutral_600="9590A0",
|
||||
neutral_200="E0DCE4",
|
||||
neutral_100="F3F1F5",
|
||||
neutral_50="F9F8FA",
|
||||
)
|
||||
|
||||
# -- Vibrant: high-saturation multi-color, data display --
|
||||
VIBRANT = _make_palette(
|
||||
primary="2563EB",
|
||||
primary_light="DBEAFE",
|
||||
accent_positive="16A34A",
|
||||
accent_negative="DC2626",
|
||||
accent_warning="EA580C",
|
||||
neutral_900="1E293B",
|
||||
neutral_600="64748B",
|
||||
neutral_200="E2E8F0",
|
||||
neutral_100="F1F5F9",
|
||||
neutral_50="F8FAFC",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §3 Premium Palettes (8 curated themes, "high-end feel" series)
|
||||
# ============================================================
|
||||
|
||||
# -- A · MUJI breathing feel: restrained minimalism, pencil on paper --
|
||||
MUJI = _make_palette(
|
||||
primary="2C2C2C",
|
||||
primary_light="F2F1EE",
|
||||
accent_positive="5B8C5A",
|
||||
accent_negative="C25450",
|
||||
accent_warning="C9A84C",
|
||||
neutral_900="2C2C2C",
|
||||
neutral_600="999999",
|
||||
neutral_200="E8E6E1",
|
||||
neutral_100="F9F9F7",
|
||||
neutral_50="FCFCFB",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- B · Aesop sandstone: earth tones, premium skincare packaging --
|
||||
AESOP = _make_palette(
|
||||
primary="3D3229",
|
||||
primary_light="EDE8E0",
|
||||
accent_positive="6B8F71",
|
||||
accent_negative="B85C4A",
|
||||
accent_warning="C4975A",
|
||||
neutral_900="4A4038",
|
||||
neutral_600="8C7B6B",
|
||||
neutral_200="DDD5C9",
|
||||
neutral_100="FAF8F5",
|
||||
neutral_50="FDFCFA",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- C · Dieter Rams Industrial: Less but better --
|
||||
DIETER_RAMS = _make_palette(
|
||||
primary="1A1A1A",
|
||||
primary_light="F7F7F7",
|
||||
accent_positive="2D8C6F",
|
||||
accent_negative="D44D3C",
|
||||
accent_warning="D4920A",
|
||||
neutral_900="1A1A1A",
|
||||
neutral_600="787878",
|
||||
neutral_200="E5E5E5",
|
||||
neutral_100="F7F7F7",
|
||||
neutral_50="FAFAFA",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- D · Kinfolk cream publication: independent magazine typography, slow-life aesthetic --
|
||||
KINFOLK = _make_palette(
|
||||
primary="5C524C",
|
||||
primary_light="F0ECE7",
|
||||
accent_positive="8DAA7F",
|
||||
accent_negative="C9776A",
|
||||
accent_warning="C9A96A",
|
||||
neutral_900="5C524C",
|
||||
neutral_600="BEB5AD",
|
||||
neutral_200="EAE5DF",
|
||||
neutral_100="FDFCFA",
|
||||
neutral_50="FEFDFB",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- E · Céline pure black-white: monochrome, fashion house coldness --
|
||||
CELINE = _make_palette(
|
||||
primary="000000",
|
||||
primary_light="FAFAFA",
|
||||
accent_positive="4A7C59",
|
||||
accent_negative="A63D2F",
|
||||
accent_warning="8C7A3C",
|
||||
neutral_900="000000",
|
||||
neutral_600="ADADAD",
|
||||
neutral_200="E0E0E0",
|
||||
neutral_100="FAFAFA",
|
||||
neutral_50="FDFDFD",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- F · Bottega dark green: Italian luxury, deep forest green --
|
||||
BOTTEGA = _make_palette(
|
||||
primary="2D4A3E",
|
||||
primary_light="E8F0EB",
|
||||
accent_positive="5FA67A",
|
||||
accent_negative="C2694B",
|
||||
accent_warning="B89B4A",
|
||||
neutral_900="3B5249",
|
||||
neutral_600="7A9B8C",
|
||||
neutral_200="D4E3DB",
|
||||
neutral_100="F6FAF8",
|
||||
neutral_50="F9FCFA",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- G · Chanel champagne gold: Chanel elegance, beige + golden brown --
|
||||
CHANEL = _make_palette(
|
||||
primary="1C1917",
|
||||
primary_light="E7DFD4",
|
||||
accent_positive="A3845B",
|
||||
accent_negative="B0413E",
|
||||
accent_warning="C4975A",
|
||||
neutral_900="1C1917",
|
||||
neutral_600="A39888",
|
||||
neutral_200="E7E0D5",
|
||||
neutral_100="FDFBF7",
|
||||
neutral_50="FEFDFB",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- H · Bloomberg deep blue: financial terminal, high-density data aesthetic --
|
||||
BLOOMBERG = _make_palette(
|
||||
primary="0D1B2A",
|
||||
primary_light="D6E0EB",
|
||||
accent_positive="10B981",
|
||||
accent_negative="EF4444",
|
||||
accent_warning="F59E0B",
|
||||
neutral_900="0D1B2A",
|
||||
neutral_600="708DA8",
|
||||
neutral_200="D6E0EB",
|
||||
neutral_100="F4F7FA",
|
||||
neutral_50="F8FAFB",
|
||||
header_text="FFFFFF",
|
||||
)
|
||||
|
||||
# -- Original Blue/Black: original blue-black color scheme (Round 1 #1/#6 style) --
|
||||
ORIGINAL_BLUE = _make_palette(
|
||||
primary="1B2A4A",
|
||||
primary_light="D6E4F0",
|
||||
accent_positive="2E8B57",
|
||||
accent_negative="EB5757",
|
||||
accent_warning="F2994A",
|
||||
neutral_900="333333",
|
||||
neutral_600="666666",
|
||||
neutral_200="E0E0E0",
|
||||
neutral_100="F5F5F5",
|
||||
neutral_50="FAFAFA",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §4 Registry
|
||||
# ============================================================
|
||||
|
||||
PALETTE_REGISTRY: Dict[str, _Palette] = {
|
||||
# Legacy (removed: fresh, vibrant)
|
||||
"professional": PROFESSIONAL,
|
||||
"warm": WARM,
|
||||
"elegant": ELEGANT,
|
||||
"creative": CREATIVE,
|
||||
# Premium (high-end feel)
|
||||
"muji": MUJI,
|
||||
"aesop": AESOP,
|
||||
# dieter_rams removed — header too dark, poor readability
|
||||
"kinfolk": KINFOLK,
|
||||
"celine": CELINE,
|
||||
"bottega": BOTTEGA,
|
||||
"chanel": CHANEL,
|
||||
"bloomberg": BLOOMBERG,
|
||||
"original_blue": ORIGINAL_BLUE,
|
||||
}
|
||||
|
||||
# Aliases for convenience
|
||||
PALETTE_REGISTRY["muji_breathing"] = MUJI
|
||||
PALETTE_REGISTRY["sandstone"] = AESOP
|
||||
PALETTE_REGISTRY["industrial"] = BLOOMBERG # was dieter_rams, redirected
|
||||
PALETTE_REGISTRY["cream"] = KINFOLK
|
||||
PALETTE_REGISTRY["monochrome"] = CELINE
|
||||
PALETTE_REGISTRY["forest_green"] = BOTTEGA
|
||||
PALETTE_REGISTRY["champagne"] = CHANEL
|
||||
PALETTE_REGISTRY["terminal"] = BLOOMBERG
|
||||
PALETTE_REGISTRY["classic_blue"] = ORIGINAL_BLUE
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §5 Keyword Matching (three-step)
|
||||
# ============================================================
|
||||
|
||||
# Step 1: Explicit style keywords (highest priority)
|
||||
_STYLE_KEYWORDS: Dict[str, list[str]] = {
|
||||
"professional": [
|
||||
"正式", "商务", "专业", "沉稳", "稳重", "professional", "formal",
|
||||
"corporate", "business",
|
||||
],
|
||||
"warm": [
|
||||
"温暖", "活力", "热情", "热烈", "暖色", "温馨", "warm", "energetic",
|
||||
"活跃", "热力",
|
||||
],
|
||||
"elegant": [
|
||||
"极简", "简约", "elegant", "minimal",
|
||||
"清新", "自然", "清爽", "淡雅", "浅色", "明亮", "fresh",
|
||||
"natural", "clean", "light", "素雅",
|
||||
"多彩", "丰富", "鲜艳", "vivid", "colorful", "明快",
|
||||
"高饱和", "鲜明", "亮色",
|
||||
],
|
||||
"creative": [
|
||||
"文艺", "个性", "紫色", "莫兰迪", "creative", "artistic",
|
||||
"柔和", "雅致",
|
||||
],
|
||||
# Premium themes
|
||||
"muji": [
|
||||
"muji", "无印", "呼吸感", "白纸", "铅笔", "素净", "无印良品",
|
||||
],
|
||||
"aesop": [
|
||||
"aesop", "沙岩", "大地色", "护肤", "泥土", "陶", "terracotta",
|
||||
],
|
||||
"bloomberg": [
|
||||
"bloomberg", "终端", "深蓝", "terminal", "金融终端", "数据终端",
|
||||
"rams", "dieter", "工业", "德系", "包豪斯", "bauhaus", "less but better",
|
||||
"工业风",
|
||||
],
|
||||
"kinfolk": [
|
||||
"kinfolk", "奶油", "刊物", "杂志", "慢生活", "latte", "拿铁",
|
||||
],
|
||||
"celine": [
|
||||
"celine", "黑白", "时装", "冷冽", "mono", "纯黑", "monochrome",
|
||||
],
|
||||
"bottega": [
|
||||
"bottega", "墨绿", "深绿", "森林", "橄榄", "绿色", "forest",
|
||||
"贵气", "奢牌",
|
||||
],
|
||||
"chanel": [
|
||||
"chanel", "米金", "金棕", "香奈儿", "champagne", "米色", "奶茶",
|
||||
],
|
||||
"original_blue": [
|
||||
"原始", "经典蓝", "classic blue", "original", "传统蓝",
|
||||
],
|
||||
}
|
||||
|
||||
# Step 2: Scene keywords → infer style (lower priority)
|
||||
_SCENE_TO_STYLE: Dict[str, str] = {
|
||||
# Sales / Marketing / Ops → warm
|
||||
"销售": "warm", "营销": "warm", "运营": "warm", "客户": "warm",
|
||||
"业绩": "warm", "KPI": "warm", "GMV": "warm", "转化": "warm",
|
||||
"漏斗": "warm", "签约": "warm", "提成": "warm", "电商": "warm",
|
||||
"sales": "warm", "marketing": "warm", "campaign": "warm",
|
||||
# Education / Medical → muji (was fresh, now removed)
|
||||
"成绩": "muji", "考试": "muji", "学生": "muji", "课程": "muji",
|
||||
"教育": "muji", "GPA": "muji", "学校": "muji", "班级": "muji",
|
||||
"医疗": "muji", "健康": "muji", "患者": "muji", "体检": "muji",
|
||||
"医院": "muji", "科室": "muji", "护理": "muji",
|
||||
"环保": "muji",
|
||||
"education": "muji", "medical": "muji", "health": "muji",
|
||||
# Design / Brand → creative
|
||||
"设计": "creative", "创意": "creative", "品牌": "creative",
|
||||
"UI": "creative", "UX": "creative", "作品": "creative",
|
||||
"视觉": "creative", "素材": "creative",
|
||||
"design": "creative", "brand": "creative", "portfolio": "creative",
|
||||
# Formal / Reporting → professional
|
||||
"汇报": "professional", "提案": "professional", "会议": "professional",
|
||||
"述职": "professional", "总结": "professional", "报告": "professional",
|
||||
"年报": "professional", "季报": "professional", "月报": "professional",
|
||||
"财务": "professional", "财报": "professional", "预算": "professional",
|
||||
"审计": "professional", "咨询": "professional", "战略": "professional",
|
||||
"finance": "professional", "budget": "professional", "report": "professional",
|
||||
# Minimal / Premium → elegant
|
||||
"premium": "elegant", "luxury": "elegant",
|
||||
# Finance data → bloomberg
|
||||
"股票": "bloomberg", "基金": "bloomberg", "投资": "bloomberg",
|
||||
"交易": "bloomberg", "行情": "bloomberg", "K线": "bloomberg",
|
||||
"stock": "bloomberg", "trading": "bloomberg", "portfolio_fin": "bloomberg",
|
||||
# High-end / Luxury brand → chanel
|
||||
"奢侈": "chanel", "高端": "chanel", "高级": "chanel",
|
||||
}
|
||||
|
||||
|
||||
def _match_style_keywords(text: str) -> Optional[str]:
|
||||
"""Step 1: Match explicit style keywords. Returns style name or None."""
|
||||
text_lower = text.lower()
|
||||
best_match = None
|
||||
best_score = 0
|
||||
for style, keywords in _STYLE_KEYWORDS.items():
|
||||
score = sum(1 for kw in keywords if kw.lower() in text_lower)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = style
|
||||
return best_match if best_score > 0 else None
|
||||
|
||||
|
||||
def _infer_from_scene(text: str) -> Optional[str]:
|
||||
"""Step 2: Infer style from scene/content keywords. Returns style name or None."""
|
||||
text_lower = text.lower()
|
||||
votes: Dict[str, int] = {}
|
||||
for keyword, style in _SCENE_TO_STYLE.items():
|
||||
if keyword.lower() in text_lower:
|
||||
votes[style] = votes.get(style, 0) + 1
|
||||
if not votes:
|
||||
return None
|
||||
return max(votes, key=votes.get)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# §6 Public API
|
||||
# ============================================================
|
||||
|
||||
def get_palette(style: str = "professional") -> _Palette:
|
||||
"""Get a palette by style name. Falls back to professional."""
|
||||
return PALETTE_REGISTRY.get(style, PROFESSIONAL)
|
||||
|
||||
|
||||
def resolve_palette(prompt: str) -> _Palette:
|
||||
"""
|
||||
Auto-detect style from user prompt (three-step):
|
||||
1. Explicit style keywords → direct match
|
||||
2. Scene/content keywords → infer style
|
||||
3. No match → professional (safe default)
|
||||
"""
|
||||
style = detect_style(prompt)
|
||||
return get_palette(style)
|
||||
|
||||
|
||||
def resolve_palette_with_info(prompt: str) -> Tuple[_Palette, str]:
|
||||
"""Same as resolve_palette but also returns the detected style name."""
|
||||
style = detect_style(prompt)
|
||||
return get_palette(style), style
|
||||
|
||||
|
||||
def detect_style(prompt: str) -> str:
|
||||
"""
|
||||
Detect style from prompt. Three-step priority:
|
||||
1. Explicit style keywords
|
||||
2. Scene keywords → infer style
|
||||
3. Default: professional
|
||||
"""
|
||||
style = _match_style_keywords(prompt)
|
||||
if style:
|
||||
return style
|
||||
style = _infer_from_scene(prompt)
|
||||
if style:
|
||||
return style
|
||||
return "professional"
|
||||
|
||||
|
||||
def list_available() -> list[str]:
|
||||
"""Return list of available style names (no aliases)."""
|
||||
# Return only canonical names, not aliases
|
||||
canonical = [
|
||||
"professional", "warm", "elegant", "creative",
|
||||
"muji", "aesop", "kinfolk", "celine", "bottega",
|
||||
"chanel", "bloomberg", "original_blue",
|
||||
]
|
||||
return canonical
|
||||
|
||||
|
||||
def apply_palette(palette: _Palette, module_globals: dict):
|
||||
"""
|
||||
Inject palette tokens into a module's global namespace.
|
||||
Designed to be called from base.py to override its color constants.
|
||||
"""
|
||||
key_map = {
|
||||
"PRIMARY": "PRIMARY",
|
||||
"PRIMARY_LIGHT": "PRIMARY_LIGHT",
|
||||
"SECONDARY": "SECONDARY",
|
||||
"ACCENT_POSITIVE": "ACCENT_POSITIVE",
|
||||
"ACCENT_NEGATIVE": "ACCENT_NEGATIVE",
|
||||
"ACCENT_WARNING": "ACCENT_WARNING",
|
||||
"NEUTRAL_900": "NEUTRAL_900",
|
||||
"NEUTRAL_600": "NEUTRAL_600",
|
||||
"NEUTRAL_200": "NEUTRAL_200",
|
||||
"NEUTRAL_100": "NEUTRAL_100",
|
||||
"NEUTRAL_50": "NEUTRAL_50",
|
||||
"NEUTRAL_0": "NEUTRAL_0",
|
||||
"CHART_COLORS": "CHART_COLORS",
|
||||
}
|
||||
for palette_key, global_key in key_map.items():
|
||||
if palette_key in palette:
|
||||
module_globals[global_key] = palette[palette_key]
|
||||
Reference in New Issue
Block a user