701 lines
19 KiB
Python
701 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
PPT Master - Unified Configuration Management Module
|
||
|
||
Centrally manages all project configuration items to ensure consistency and maintainability.
|
||
|
||
Usage:
|
||
from config import Config, CANVAS_FORMATS, DESIGN_COLORS
|
||
|
||
# Get canvas format
|
||
ppt169 = Config.get_canvas_format('ppt169')
|
||
|
||
# Get color scheme
|
||
colors = Config.get_color_scheme('consulting')
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Any
|
||
|
||
from console_encoding import configure_utf8_stdio
|
||
|
||
configure_utf8_stdio()
|
||
|
||
|
||
# ============================================================
|
||
# Path Configuration
|
||
# ============================================================
|
||
|
||
# Project root directory
|
||
PROJECT_ROOT = Path(__file__).parent.parent
|
||
|
||
# Core directories
|
||
SCRIPTS_DIR = PROJECT_ROOT / 'scripts'
|
||
REFERENCES_DIR = PROJECT_ROOT / 'references'
|
||
TEMPLATES_DIR = PROJECT_ROOT / 'templates'
|
||
WORKFLOWS_DIR = PROJECT_ROOT / 'workflows'
|
||
|
||
# Repository root directory
|
||
REPO_ROOT = PROJECT_ROOT.parent.parent
|
||
EXAMPLES_DIR = REPO_ROOT / 'examples'
|
||
PROJECTS_DIR = REPO_ROOT / 'projects'
|
||
|
||
# Template subdirectories
|
||
CHART_TEMPLATES_DIR = TEMPLATES_DIR / 'charts'
|
||
|
||
|
||
# ============================================================
|
||
# Environment Configuration
|
||
# ============================================================
|
||
|
||
USER_CONFIG_DIR = Path.home() / '.ppt-master'
|
||
USER_ENV_FILE = USER_CONFIG_DIR / '.env'
|
||
|
||
|
||
def get_env_candidates() -> list[Path]:
|
||
"""Return the supported .env lookup order."""
|
||
return [
|
||
Path.cwd() / '.env',
|
||
PROJECT_ROOT / '.env',
|
||
REPO_ROOT / '.env',
|
||
USER_ENV_FILE,
|
||
]
|
||
|
||
|
||
def resolve_env_path() -> Path:
|
||
"""
|
||
Return the first existing .env path.
|
||
|
||
If no candidate exists, return the CWD .env path so callers can no-op
|
||
consistently while still showing a useful default location in messages.
|
||
"""
|
||
candidates = get_env_candidates()
|
||
for candidate in candidates:
|
||
if candidate.exists():
|
||
return candidate
|
||
return candidates[0]
|
||
|
||
|
||
def strip_env_quotes(value: str) -> str:
|
||
"""Strip matching surrounding quotes from a .env value."""
|
||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||
return value[1:-1]
|
||
return value
|
||
|
||
|
||
def strip_inline_env_comment(value: str) -> str:
|
||
"""Strip an unquoted inline ``#`` comment from a .env value.
|
||
|
||
Matches standard dotenv behavior: a ``#`` outside surrounding quotes
|
||
starts a comment and is dropped along with the rest of the line. To keep
|
||
a literal ``#`` in the value, wrap it in single or double quotes.
|
||
"""
|
||
stripped = value.lstrip()
|
||
if stripped.startswith(('"', "'")):
|
||
quote = stripped[0]
|
||
end = stripped.find(quote, 1)
|
||
if end != -1:
|
||
head = value[: len(value) - len(stripped) + end + 1]
|
||
tail = value[len(head):]
|
||
hash_pos = tail.find('#')
|
||
if hash_pos == -1:
|
||
return value
|
||
return head + tail[:hash_pos]
|
||
return value
|
||
hash_pos = value.find('#')
|
||
if hash_pos == -1:
|
||
return value
|
||
return value[:hash_pos]
|
||
|
||
|
||
def load_prefixed_env_file(
|
||
prefixes: tuple[str, ...],
|
||
*,
|
||
deprecated_keys: Optional[dict[str, str]] = None,
|
||
) -> Optional[Path]:
|
||
"""
|
||
Load matching keys from the first supported .env file.
|
||
|
||
Existing process environment variables always win. Keys outside the
|
||
requested prefixes are ignored so one shared .env can hold image, search,
|
||
and narration credentials without leaking unrelated values into the
|
||
process.
|
||
"""
|
||
env_path = resolve_env_path()
|
||
if not env_path.exists():
|
||
return None
|
||
|
||
deprecated_keys = deprecated_keys or {}
|
||
with env_path.open('r', encoding='utf-8') as fh:
|
||
for lineno, raw_line in enumerate(fh, start=1):
|
||
line = raw_line.strip()
|
||
if not line or line.startswith('#'):
|
||
continue
|
||
if line.startswith('export '):
|
||
line = line[7:].lstrip()
|
||
if '=' not in line:
|
||
raise ValueError(
|
||
f"Invalid line in {env_path}:{lineno}. Expected KEY=VALUE."
|
||
)
|
||
|
||
key, value = line.split('=', 1)
|
||
key = key.strip()
|
||
if not key:
|
||
raise ValueError(
|
||
f"Invalid line in {env_path}:{lineno}. Missing variable name."
|
||
)
|
||
if not any(key.startswith(prefix) for prefix in prefixes):
|
||
continue
|
||
if key in deprecated_keys:
|
||
raise ValueError(
|
||
f"Unsupported key in {env_path}:{lineno}: {key}\n"
|
||
f"{deprecated_keys[key]}"
|
||
)
|
||
cleaned = strip_inline_env_comment(value).strip()
|
||
os.environ.setdefault(key, strip_env_quotes(cleaned))
|
||
|
||
return env_path
|
||
|
||
|
||
# ============================================================
|
||
# Canvas Format Configuration
|
||
# ============================================================
|
||
|
||
CANVAS_FORMATS = {
|
||
'ppt169': {
|
||
'name': 'PPT 16:9',
|
||
'dimensions': '1280×720',
|
||
'viewbox': '0 0 1280 720',
|
||
'width': 1280,
|
||
'height': 720,
|
||
'aspect_ratio': '16:9',
|
||
'use_case': 'Modern projectors, online presentations'
|
||
},
|
||
'ppt43': {
|
||
'name': 'PPT 4:3',
|
||
'dimensions': '1024×768',
|
||
'viewbox': '0 0 1024 768',
|
||
'width': 1024,
|
||
'height': 768,
|
||
'aspect_ratio': '4:3',
|
||
'use_case': 'Traditional projectors'
|
||
},
|
||
'wechat': {
|
||
'name': 'WeChat Article Header',
|
||
'dimensions': '900×383',
|
||
'viewbox': '0 0 900 383',
|
||
'width': 900,
|
||
'height': 383,
|
||
'aspect_ratio': '2.35:1',
|
||
'use_case': 'WeChat article cover images'
|
||
},
|
||
'xiaohongshu': {
|
||
'name': '小红书',
|
||
'dimensions': '1242×1660',
|
||
'viewbox': '0 0 1242 1660',
|
||
'width': 1242,
|
||
'height': 1660,
|
||
'aspect_ratio': '3:4',
|
||
'use_case': 'Knowledge sharing, product reviews'
|
||
},
|
||
'moments': {
|
||
'name': 'Moments/Instagram',
|
||
'dimensions': '1080×1080',
|
||
'viewbox': '0 0 1080 1080',
|
||
'width': 1080,
|
||
'height': 1080,
|
||
'aspect_ratio': '1:1',
|
||
'use_case': 'Social media square images'
|
||
},
|
||
'story': {
|
||
'name': 'Story/Vertical',
|
||
'dimensions': '1080×1920',
|
||
'viewbox': '0 0 1080 1920',
|
||
'width': 1080,
|
||
'height': 1920,
|
||
'aspect_ratio': '9:16',
|
||
'use_case': 'Short video covers, stories'
|
||
},
|
||
'banner': {
|
||
'name': 'Horizontal Banner',
|
||
'dimensions': '1920×1080',
|
||
'viewbox': '0 0 1920 1080',
|
||
'width': 1920,
|
||
'height': 1080,
|
||
'aspect_ratio': '16:9',
|
||
'use_case': 'Web banners, large screen displays'
|
||
},
|
||
'a4': {
|
||
'name': 'A4 Print',
|
||
'dimensions': '1240×1754',
|
||
'viewbox': '0 0 1240 1754',
|
||
'width': 1240,
|
||
'height': 1754,
|
||
'aspect_ratio': '√2:1',
|
||
'use_case': 'Print documents, PDF export'
|
||
}
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Design Color Configuration
|
||
# ============================================================
|
||
|
||
DESIGN_COLORS = {
|
||
'consulting': {
|
||
'name': 'Consulting Style',
|
||
'primary': '#005587',
|
||
'secondary': '#0076A8',
|
||
'accent': '#F5A623',
|
||
'success': '#27AE60',
|
||
'warning': '#E74C3C',
|
||
'text_dark': '#1A252F',
|
||
'text_light': '#FFFFFF',
|
||
'text_muted': '#7F8C8D',
|
||
'background': '#FFFFFF',
|
||
'background_alt': '#F8F9FA'
|
||
},
|
||
'general': {
|
||
'name': 'General Flexible Style',
|
||
'primary': '#2196F3',
|
||
'secondary': '#4CAF50',
|
||
'accent': '#FF9800',
|
||
'purple': '#9C27B0',
|
||
'success': '#27AE60',
|
||
'warning': '#E74C3C',
|
||
'text_dark': '#2C3E50',
|
||
'text_light': '#FFFFFF',
|
||
'text_muted': '#7F8C8D',
|
||
'background': '#FFFFFF',
|
||
'background_alt': '#F8F9FA'
|
||
},
|
||
'tech': {
|
||
'name': 'Tech Style',
|
||
'primary': '#00D1FF',
|
||
'secondary': '#7B61FF',
|
||
'accent': '#00FF88',
|
||
'success': '#00FF88',
|
||
'warning': '#FF6B6B',
|
||
'text_dark': '#0A0E17',
|
||
'text_light': '#FFFFFF',
|
||
'text_muted': '#8892A0',
|
||
'background': '#0A0E17',
|
||
'background_alt': '#1A1F2E'
|
||
},
|
||
'academic': {
|
||
'name': 'Academic Style',
|
||
'primary': '#8B0000',
|
||
'secondary': '#1E3A5F',
|
||
'accent': '#C9B037',
|
||
'success': '#2E7D32',
|
||
'warning': '#D32F2F',
|
||
'text_dark': '#1A1A1A',
|
||
'text_light': '#FFFFFF',
|
||
'text_muted': '#666666',
|
||
'background': '#FFFFFF',
|
||
'background_alt': '#F5F5F5'
|
||
},
|
||
'government': {
|
||
'name': 'Government Style',
|
||
'primary': '#C41E3A',
|
||
'secondary': '#1E3A5F',
|
||
'accent': '#D4AF37',
|
||
'success': '#2E7D32',
|
||
'warning': '#B71C1C',
|
||
'text_dark': '#1A1A1A',
|
||
'text_light': '#FFFFFF',
|
||
'text_muted': '#555555',
|
||
'background': '#FFFFFF',
|
||
'background_alt': '#FFF8E1'
|
||
}
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Industry Color Templates
|
||
# ============================================================
|
||
|
||
INDUSTRY_COLORS = {
|
||
'finance': {
|
||
'name': 'Finance/Banking',
|
||
'primary': '#003366',
|
||
'secondary': '#4A90D9',
|
||
'accent': '#D4AF37'
|
||
},
|
||
'healthcare': {
|
||
'name': 'Healthcare/Medical',
|
||
'primary': '#00796B',
|
||
'secondary': '#4DB6AC',
|
||
'accent': '#FF7043'
|
||
},
|
||
'technology': {
|
||
'name': 'Technology/Internet',
|
||
'primary': '#1565C0',
|
||
'secondary': '#42A5F5',
|
||
'accent': '#00E676'
|
||
},
|
||
'education': {
|
||
'name': 'Education/Training',
|
||
'primary': '#5E35B1',
|
||
'secondary': '#7E57C2',
|
||
'accent': '#FFD54F'
|
||
},
|
||
'retail': {
|
||
'name': 'Retail/Consumer',
|
||
'primary': '#E53935',
|
||
'secondary': '#EF5350',
|
||
'accent': '#FFB300'
|
||
},
|
||
'manufacturing': {
|
||
'name': 'Manufacturing/Industrial',
|
||
'primary': '#455A64',
|
||
'secondary': '#78909C',
|
||
'accent': '#FF6F00'
|
||
},
|
||
'energy': {
|
||
'name': 'Energy/Environmental',
|
||
'primary': '#2E7D32',
|
||
'secondary': '#66BB6A',
|
||
'accent': '#FDD835'
|
||
},
|
||
'realestate': {
|
||
'name': 'Real Estate/Construction',
|
||
'primary': '#795548',
|
||
'secondary': '#A1887F',
|
||
'accent': '#4CAF50'
|
||
},
|
||
'legal': {
|
||
'name': 'Legal/Compliance',
|
||
'primary': '#37474F',
|
||
'secondary': '#546E7A',
|
||
'accent': '#8D6E63'
|
||
},
|
||
'media': {
|
||
'name': 'Media/Entertainment',
|
||
'primary': '#7B1FA2',
|
||
'secondary': '#AB47BC',
|
||
'accent': '#FF4081'
|
||
},
|
||
'logistics': {
|
||
'name': 'Logistics/Supply Chain',
|
||
'primary': '#F57C00',
|
||
'secondary': '#FFB74D',
|
||
'accent': '#0288D1'
|
||
},
|
||
'agriculture': {
|
||
'name': 'Agriculture/Food',
|
||
'primary': '#558B2F',
|
||
'secondary': '#8BC34A',
|
||
'accent': '#FFCA28'
|
||
},
|
||
'tourism': {
|
||
'name': 'Tourism/Hospitality',
|
||
'primary': '#00ACC1',
|
||
'secondary': '#4DD0E1',
|
||
'accent': '#FF7043'
|
||
},
|
||
'automotive': {
|
||
'name': 'Automotive/Transportation',
|
||
'primary': '#263238',
|
||
'secondary': '#455A64',
|
||
'accent': '#D32F2F'
|
||
}
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Font Configuration
|
||
# ============================================================
|
||
|
||
FONTS = {
|
||
'system_ui': "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||
'sans_serif': "'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||
'monospace': "'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace"
|
||
}
|
||
|
||
FONT_SIZES = {
|
||
'title_large': 48,
|
||
'title': 36,
|
||
'title_small': 28,
|
||
'heading': 24,
|
||
'subheading': 20,
|
||
'body': 18,
|
||
'body_small': 16,
|
||
'caption': 14,
|
||
'footnote': 12
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Layout Configuration
|
||
# ============================================================
|
||
|
||
LAYOUT_MARGINS = {
|
||
'ppt169': {
|
||
'top': 60,
|
||
'right': 60,
|
||
'bottom': 60,
|
||
'left': 60,
|
||
'content_width': 1160,
|
||
'content_height': 600
|
||
},
|
||
'ppt43': {
|
||
'top': 50,
|
||
'right': 50,
|
||
'bottom': 50,
|
||
'left': 50,
|
||
'content_width': 924,
|
||
'content_height': 608
|
||
},
|
||
'xiaohongshu': {
|
||
'top': 80,
|
||
'right': 60,
|
||
'bottom': 80,
|
||
'left': 60,
|
||
'content_width': 1122,
|
||
'content_height': 1500
|
||
},
|
||
'moments': {
|
||
'top': 60,
|
||
'right': 60,
|
||
'bottom': 60,
|
||
'left': 60,
|
||
'content_width': 960,
|
||
'content_height': 960
|
||
},
|
||
'story': {
|
||
'top': 120,
|
||
'right': 60,
|
||
'bottom': 180,
|
||
'left': 60,
|
||
'content_width': 960,
|
||
'content_height': 1620
|
||
},
|
||
'wechat': {
|
||
'top': 40,
|
||
'right': 40,
|
||
'bottom': 40,
|
||
'left': 40,
|
||
'content_width': 820,
|
||
'content_height': 303
|
||
},
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# SVG Policy Reference
|
||
# ============================================================
|
||
|
||
# Do not mirror element/attribute rules here. The shared standard is the
|
||
# authoring authority and the quality checker enforces it.
|
||
# Keep the exported key for compatibility with existing config consumers.
|
||
SVG_CONSTRAINTS = {
|
||
'authority': 'skills/ppt-master/references/shared-standards.md',
|
||
'validator': 'skills/ppt-master/scripts/svg_quality_checker.py',
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# Configuration Manager Class
|
||
# ============================================================
|
||
|
||
class Config:
|
||
"""Configuration manager."""
|
||
|
||
@staticmethod
|
||
def get_canvas_format(format_key: str) -> Optional[Dict]:
|
||
"""
|
||
Get canvas format configuration.
|
||
|
||
Args:
|
||
format_key: Format key name (e.g. 'ppt169', 'xiaohongshu')
|
||
|
||
Returns:
|
||
Format configuration dict, or None if not found
|
||
"""
|
||
return CANVAS_FORMATS.get(format_key)
|
||
|
||
@staticmethod
|
||
def get_all_canvas_formats() -> Dict:
|
||
"""Get all canvas formats."""
|
||
return CANVAS_FORMATS.copy()
|
||
|
||
@staticmethod
|
||
def get_color_scheme(style: str) -> Optional[Dict]:
|
||
"""
|
||
Get color scheme.
|
||
|
||
Args:
|
||
style: Style name (e.g. 'consulting', 'general', 'tech')
|
||
|
||
Returns:
|
||
Color scheme dict
|
||
"""
|
||
return DESIGN_COLORS.get(style)
|
||
|
||
@staticmethod
|
||
def get_industry_colors(industry: str) -> Optional[Dict]:
|
||
"""
|
||
Get industry color palette.
|
||
|
||
Args:
|
||
industry: Industry name (e.g. 'finance', 'healthcare')
|
||
|
||
Returns:
|
||
Industry color dict
|
||
"""
|
||
return INDUSTRY_COLORS.get(industry)
|
||
|
||
@staticmethod
|
||
def get_all_industries() -> List[str]:
|
||
"""Get list of all industries."""
|
||
return list(INDUSTRY_COLORS.keys())
|
||
|
||
@staticmethod
|
||
def get_layout_margins(format_key: str) -> Optional[Dict]:
|
||
"""
|
||
Get layout margin configuration.
|
||
|
||
Args:
|
||
format_key: Format key name
|
||
|
||
Returns:
|
||
Margin configuration dict
|
||
"""
|
||
return LAYOUT_MARGINS.get(format_key)
|
||
|
||
@staticmethod
|
||
def get_font(font_type: str = 'system_ui') -> str:
|
||
"""
|
||
Get font declaration.
|
||
|
||
Args:
|
||
font_type: Font type ('system_ui', 'sans_serif', 'monospace')
|
||
|
||
Returns:
|
||
Font declaration string
|
||
"""
|
||
return FONTS.get(font_type, FONTS['system_ui'])
|
||
|
||
@staticmethod
|
||
def get_font_size(size_name: str) -> int:
|
||
"""
|
||
Get font size.
|
||
|
||
Args:
|
||
size_name: Size name (e.g. 'title', 'body', 'caption')
|
||
|
||
Returns:
|
||
Font size (pixels)
|
||
"""
|
||
return FONT_SIZES.get(size_name, FONT_SIZES['body'])
|
||
|
||
@staticmethod
|
||
def get_project_path(subdir: str = '') -> Path:
|
||
"""
|
||
Get project path.
|
||
|
||
Args:
|
||
subdir: Subdirectory name
|
||
|
||
Returns:
|
||
Full path
|
||
"""
|
||
if subdir:
|
||
return PROJECT_ROOT / subdir
|
||
return PROJECT_ROOT
|
||
|
||
@staticmethod
|
||
def export_config(output_file: str = 'config_export.json'):
|
||
"""
|
||
Export configuration to a JSON file.
|
||
|
||
Args:
|
||
output_file: Output file path
|
||
"""
|
||
config_data = {
|
||
'canvas_formats': CANVAS_FORMATS,
|
||
'design_colors': DESIGN_COLORS,
|
||
'industry_colors': INDUSTRY_COLORS,
|
||
'fonts': FONTS,
|
||
'font_sizes': FONT_SIZES,
|
||
'svg_constraints': SVG_CONSTRAINTS
|
||
}
|
||
|
||
with open(output_file, 'w', encoding='utf-8') as f:
|
||
json.dump(config_data, f, ensure_ascii=False, indent=2)
|
||
|
||
print(f"Configuration exported to: {output_file}")
|
||
|
||
|
||
# ============================================================
|
||
# Command Line Interface
|
||
# ============================================================
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
"""Build the command-line parser."""
|
||
parser = argparse.ArgumentParser(
|
||
description="PPT Master configuration management tool.",
|
||
)
|
||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||
subparsers.add_parser("list-formats", help="List all canvas formats")
|
||
subparsers.add_parser("list-colors", help="List all color schemes")
|
||
subparsers.add_parser("list-industries", help="List all industry colors")
|
||
|
||
export = subparsers.add_parser("export", help="Export configuration to JSON")
|
||
export.add_argument(
|
||
"output_path",
|
||
nargs="?",
|
||
help="Output JSON path (backward-compatible positional form)",
|
||
)
|
||
export.add_argument(
|
||
"-o",
|
||
"--output",
|
||
default=None,
|
||
help="Output JSON path (default: config_export.json)",
|
||
)
|
||
|
||
format_parser = subparsers.add_parser("format", help="View a specific canvas format")
|
||
format_parser.add_argument("key", choices=sorted(CANVAS_FORMATS), help="Canvas format key")
|
||
return parser
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""Command line entry point."""
|
||
parser = build_parser()
|
||
args = parser.parse_args(argv)
|
||
|
||
if args.command == 'list-formats':
|
||
print("\nCanvas Format List:\n")
|
||
for key, info in CANVAS_FORMATS.items():
|
||
print(
|
||
f" {key:15} | {info['name']:15} | {info['dimensions']:12} | {info['use_case']}")
|
||
|
||
elif args.command == 'list-colors':
|
||
print("\nColor Scheme List:\n")
|
||
for key, info in DESIGN_COLORS.items():
|
||
print(f" {key:12} | {info['name']:15} | Primary: {info['primary']}")
|
||
|
||
elif args.command == 'list-industries':
|
||
print("\nIndustry Color List:\n")
|
||
for key, info in INDUSTRY_COLORS.items():
|
||
print(f" {key:15} | {info['name']:15} | Primary: {info['primary']}")
|
||
|
||
elif args.command == 'export':
|
||
Config.export_config(args.output or args.output_path or "config_export.json")
|
||
|
||
elif args.command == 'format':
|
||
info = Config.get_canvas_format(args.key)
|
||
print(f"\nCanvas Format: {args.key}\n")
|
||
for key, value in info.items():
|
||
print(f" {key}: {value}")
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|