Files
mantle-ai-trader/skills/xlsx/templates/base.py
2026-06-06 05:21:10 +00:00

633 lines
21 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
)