2817 lines
120 KiB
Python
Executable File
2817 lines
120 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
design_engine.py — Aesthetic computation engine for art-direction-first PDF production.
|
||
|
||
Philosophy: The LLM handles narrative and composition; this script handles the math
|
||
that models get wrong — precise color harmony, algorithmic SVG, and spatial tension.
|
||
|
||
Three core functions:
|
||
1. generate_color_palette(intent) — HSL-locked low-saturation palettes
|
||
2. generate_generative_svg(svg_type) — Algorithmic art backgrounds
|
||
3. calculate_layout(elements) — Deliberate offset/overlap coordinates
|
||
|
||
Usage:
|
||
python3 design_engine.py palette --intent calm --mode dark
|
||
python3 design_engine.py palette --intent tension --mode light
|
||
python3 design_engine.py svg --intent flow --dimensions 720x960
|
||
python3 design_engine.py svg --intent grid --dimensions 720x960
|
||
python3 design_engine.py layout --elements hero,body,meta --dimensions 720x960 --style offset
|
||
python3 design_engine.py full --intent energy --mode dark --dimensions 720x960 --output-dir ./assets/
|
||
"""
|
||
|
||
import argparse
|
||
import colorsys
|
||
import json
|
||
import math
|
||
import os
|
||
import random
|
||
import sys
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# 1. COLOR PALETTE — HSL with Saturation Clamped to [0.05, 0.25]
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
#
|
||
# Core constraint: Saturation MUST be between 0.05 and 0.25.
|
||
# This eliminates candy-colored, web-app-looking outputs and locks
|
||
# everything into the "high-end grey" (高级灰) tonal range.
|
||
#
|
||
# Two modes:
|
||
# - Dark: background L < 0.10, text L > 0.85
|
||
# - Light: background L > 0.95, text L < 0.15
|
||
# The "muddy middle" (L between 0.30 and 0.70) is forbidden for backgrounds.
|
||
|
||
# Intent → base hue mapping (degrees on the HSL wheel)
|
||
# 5 intents: Calm, Tension, Energy, Authority, Warmth
|
||
# Plus utility intents (nature, cold, neutral) for keyword auto-derive
|
||
INTENT_HUES = {
|
||
"calm": 210, # Steel blue-grey, low saturation (merges old serenity+minimalism)
|
||
"tension": 0, # Warm near-black vs cold
|
||
"energy": 30, # Amber undertone
|
||
"authority": 280, # Muted violet, formal/premium (replaces old elegance)
|
||
"warmth": 20, # Terracotta undertone
|
||
# Utility intents (for keyword auto-derive, not exposed in UI)
|
||
"nature": 150, # Desaturated sage
|
||
"cold": 200, # Slate blue
|
||
"neutral": 45, # Warm grey
|
||
# Legacy aliases (backward compatibility)
|
||
"serenity": 210, # → calm
|
||
"elegance": 280, # → authority
|
||
"minimalism": 0, # → calm (achromatic variant)
|
||
}
|
||
|
||
# Theme keywords → intent mapping (for auto-derive from document description)
|
||
# When the user doesn't specify an intent, scan the document title/description
|
||
# for these keywords and map to the closest intent.
|
||
THEME_KEYWORDS = {
|
||
# Technology / Data / Analytics
|
||
"tech": "cold", "数据": "cold", "data": "cold", "AI": "cold",
|
||
"科技": "cold", "digital": "cold", "analytics": "cold", "分析": "cold",
|
||
# Nature / Environment / Sustainability
|
||
"green": "nature", "绿色": "nature", "环保": "nature", "eco": "nature",
|
||
"sustainability": "nature", "生态": "nature", "forest": "nature",
|
||
# Business / Finance / Corporate
|
||
"report": "neutral", "报告": "neutral", "finance": "neutral", "财务": "neutral",
|
||
"annual": "neutral", "年度": "neutral", "corporate": "neutral",
|
||
# Creative / Marketing / Social
|
||
"marketing": "energy", "运营": "energy", "social": "energy", "品牌": "energy",
|
||
"campaign": "energy", "活动": "energy", "launch": "energy",
|
||
# Authority / Formal / Premium / Luxury
|
||
"luxury": "authority", "奢华": "authority", "fashion": "authority", "时尚": "authority",
|
||
"premium": "authority", "高端": "authority", "gala": "authority",
|
||
"formal": "authority", "正式": "authority", "professional": "authority", "专业": "authority",
|
||
"government": "authority", "政府": "authority", "bidding": "authority", "投标": "authority",
|
||
"政府报告": "authority", "政府文书": "authority", "公文": "authority",
|
||
"thesis": "authority", "毕业论文": "authority", "dissertation": "authority",
|
||
"开题": "authority", "开题报告": "authority", "proposal": "authority", "学位": "authority",
|
||
# Calm / Meditation / Healthcare / Minimalist
|
||
"health": "calm", "健康": "calm", "meditation": "calm",
|
||
"wellness": "calm", "calm": "calm", "医疗": "calm",
|
||
"minimalist": "calm", "极简": "calm", "simple": "calm", "简约": "calm",
|
||
# Urgent / Warning / Emergency
|
||
"urgent": "tension", "warning": "tension", "紧急": "tension",
|
||
"alert": "tension", "crisis": "tension",
|
||
# Warm / Food / Lifestyle
|
||
"food": "warmth", "美食": "warmth", "lifestyle": "warmth",
|
||
"生活": "warmth", "home": "warmth", "家居": "warmth",
|
||
}
|
||
|
||
|
||
def derive_intent(text):
|
||
"""
|
||
Auto-derive design intent from document title/description.
|
||
Scans for theme keywords and returns the best-matching intent.
|
||
Falls back to 'neutral' if no keywords match.
|
||
|
||
Usage:
|
||
python3 design_engine.py derive "Social Media Operations Monthly Report" → energy
|
||
python3 design_engine.py derive "2025 Annual Sustainability Report" → nature
|
||
"""
|
||
text_lower = text.lower()
|
||
scores = {}
|
||
for keyword, intent in THEME_KEYWORDS.items():
|
||
if keyword.lower() in text_lower:
|
||
scores[intent] = scores.get(intent, 0) + 1
|
||
if not scores:
|
||
return "neutral"
|
||
# When tied, prefer specific intents over 'neutral' (which is a generic fallback)
|
||
max_score = max(scores.values())
|
||
top_intents = [k for k, v in scores.items() if v == max_score]
|
||
if len(top_intents) > 1 and "neutral" in top_intents:
|
||
top_intents.remove("neutral")
|
||
return top_intents[0]
|
||
|
||
# Intent → recommended harmony mapping (reduces LLM decision burden)
|
||
# Used as fallback when LLM doesn't specify color_harmony
|
||
INTENT_HARMONY_MAP = {
|
||
"calm": "analogous", # peaceful, flowing transition (merges serenity+minimalism)
|
||
"tension": "complementary", # maximum visual conflict
|
||
"energy": "triadic", # vibrant, multi-directional
|
||
"authority": "split_complementary", # sophisticated, formal (replaces elegance)
|
||
"warmth": "analogous", # natural, earthy cohesion
|
||
# Utility intents
|
||
"nature": "analogous", # organic harmony
|
||
"cold": "split_complementary", # icy precision with subtle warmth
|
||
"neutral": "split_complementary", # safe default, still interesting
|
||
# Legacy aliases
|
||
"serenity": "analogous",
|
||
"elegance": "split_complementary",
|
||
"minimalism": "monochrome",
|
||
}
|
||
|
||
def _hsl_to_hex(h, s, l):
|
||
"""Convert HSL (h: 0-360, s: 0-1, l: 0-1) to hex string."""
|
||
r, g, b = colorsys.hls_to_rgb(h / 360.0, l, s)
|
||
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
|
||
|
||
|
||
# Muddy / ugly hue zones that produce unattractive accent colors at medium saturation.
|
||
# These hue ranges tend to look "dirty" or "sickly" — yellow-green, brown-orange, etc.
|
||
# When an accent lands here, we nudge it to the nearest attractive neighbor.
|
||
_UGLY_HUE_ZONES = [
|
||
# (start, end, nudge_low, nudge_high) — hues that look muddy
|
||
# at S 0.4-0.7, L 0.35-0.55. When accent lands here, redirect.
|
||
(28, 105, 15, 120), # Dirty yellow/brown/olive/yellow-green zone
|
||
# nudge_low=15 (warm red-orange), nudge_high=120 (true green)
|
||
]
|
||
|
||
|
||
def _sanitize_accent_hue(hue):
|
||
"""Nudge accent hue away from muddy/ugly zones toward attractive neighbors."""
|
||
hue = hue % 360
|
||
for start, end, nudge_low, nudge_high in _UGLY_HUE_ZONES:
|
||
if start <= hue <= end:
|
||
mid = (start + end) / 2
|
||
return nudge_low if hue < mid else nudge_high
|
||
return hue
|
||
|
||
def _hex_to_rgb(hex_str):
|
||
"""Convert hex to 'r,g,b' string for rgba() usage."""
|
||
hex_str = hex_str.lstrip('#')
|
||
return f"{int(hex_str[0:2], 16)},{int(hex_str[2:4], 16)},{int(hex_str[4:6], 16)}"
|
||
|
||
def _relative_luminance(hex_str):
|
||
"""WCAG 2.1 relative luminance from hex color."""
|
||
hex_str = hex_str.lstrip('#')
|
||
channels = []
|
||
for i in (0, 2, 4):
|
||
c = int(hex_str[i:i+2], 16) / 255.0
|
||
channels.append(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4)
|
||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
||
|
||
def _contrast_ratio(hex1, hex2):
|
||
"""WCAG contrast ratio between two hex colors."""
|
||
l1, l2 = _relative_luminance(hex1), _relative_luminance(hex2)
|
||
lighter, darker = max(l1, l2), min(l1, l2)
|
||
return (lighter + 0.05) / (darker + 0.05)
|
||
|
||
def generate_color_palette(intent="neutral", mode="minimal", harmony=None, seed=None):
|
||
"""
|
||
Generative Color Harmony Engine — geometric accent computation + 5 aesthetic modes.
|
||
|
||
LLM is FORBIDDEN from specifying HEX/RGB. It only selects:
|
||
- intent → base hue (from INTENT_HUES)
|
||
- mode → S/L physical boundaries (minimal/dark/pastel/jewel/light)
|
||
- harmony → accent hue geometry (auto-recommended from intent if omitted)
|
||
|
||
Returns dict with:
|
||
- bg: 60% — dominant ground
|
||
- mid: 30% — structural shade
|
||
- accent: 10% — geometric harmony emphasis
|
||
- text: primary text color
|
||
- muted: secondary/caption text
|
||
- surface: card/container background (translucent)
|
||
"""
|
||
if seed is not None:
|
||
random.seed(seed)
|
||
|
||
# Auto-recommend harmony from intent if not specified
|
||
if harmony is None or harmony == "auto":
|
||
harmony = INTENT_HARMONY_MAP.get(intent, "split_complementary")
|
||
|
||
base_hue = INTENT_HUES.get(intent, random.randint(0, 359))
|
||
|
||
# 1. Geometric Accent Hue Derivation
|
||
if harmony == "complementary":
|
||
accent_hue = (base_hue + 180) % 360
|
||
elif harmony == "split_complementary":
|
||
accent_hue = (base_hue + random.choice([150, 210])) % 360
|
||
elif harmony == "triadic":
|
||
accent_hue = (base_hue + random.choice([120, 240])) % 360
|
||
elif harmony == "analogous":
|
||
accent_hue = (base_hue + random.choice([30, -30, 45, -45])) % 360
|
||
else: # monochrome
|
||
accent_hue = base_hue
|
||
|
||
# Sanitize accent hue — avoid muddy yellow-green and brown-orange zones
|
||
accent_hue = _sanitize_accent_hue(accent_hue)
|
||
|
||
# 2. Five Aesthetic Modes — hard-lock S and L boundaries
|
||
if mode == "minimal":
|
||
# DEFAULT for 50%+ of documents. Editorial paper tone + high-purity accent.
|
||
# bg has visible tint (S 0.08-0.15, L 0.92-0.96) — not dead white.
|
||
is_warm = random.choice([True, False])
|
||
paper_hue = random.randint(35, 45) if is_warm else random.randint(200, 215)
|
||
bg = _hsl_to_hex(paper_hue, random.uniform(0.08, 0.15), random.uniform(0.92, 0.96))
|
||
mid = _hsl_to_hex(paper_hue, random.uniform(0.10, 0.18), random.uniform(0.85, 0.90))
|
||
accent = _hsl_to_hex(accent_hue, random.uniform(0.55, 0.72), random.uniform(0.42, 0.52))
|
||
text = _hsl_to_hex(paper_hue, 0.05, random.uniform(0.10, 0.15))
|
||
muted = _hsl_to_hex(paper_hue, 0.05, random.uniform(0.45, 0.55))
|
||
surface = f"rgba(0,0,0,{random.uniform(0.01, 0.03):.2f})"
|
||
|
||
elif mode == "dark":
|
||
# Cyber/tech: ultra-low saturation, ultra-low lightness
|
||
bg = _hsl_to_hex(base_hue, random.uniform(0.04, 0.10), random.uniform(0.04, 0.08))
|
||
mid = _hsl_to_hex(base_hue, random.uniform(0.08, 0.15), random.uniform(0.12, 0.20))
|
||
accent = _hsl_to_hex(accent_hue, random.uniform(0.45, 0.60), random.uniform(0.52, 0.62))
|
||
text = _hsl_to_hex(base_hue, 0.05, random.uniform(0.88, 0.95))
|
||
muted = _hsl_to_hex(base_hue, 0.05, random.uniform(0.45, 0.55))
|
||
surface = f"rgba(255,255,255,{random.uniform(0.03, 0.06):.2f})"
|
||
|
||
elif mode == "pastel":
|
||
# Morandi/macaron: medium-low saturation, high lightness
|
||
# Accent tightened: S capped at 0.50 to avoid clashing with tinted bg
|
||
bg = _hsl_to_hex(base_hue, random.uniform(0.15, 0.35), random.uniform(0.85, 0.92))
|
||
mid = _hsl_to_hex(base_hue, random.uniform(0.20, 0.40), random.uniform(0.75, 0.82))
|
||
accent = _hsl_to_hex(accent_hue, random.uniform(0.38, 0.50), random.uniform(0.38, 0.48))
|
||
text = _hsl_to_hex(base_hue, 0.15, random.uniform(0.15, 0.25))
|
||
muted = _hsl_to_hex(base_hue, 0.20, random.uniform(0.45, 0.55))
|
||
surface = f"rgba(255,255,255,{random.uniform(0.30, 0.50):.2f})"
|
||
|
||
elif mode == "jewel":
|
||
# Gem/luxury: medium-high saturation bg, accent must NOT compete — lower S, higher L
|
||
bg = _hsl_to_hex(base_hue, random.uniform(0.40, 0.60), random.uniform(0.15, 0.25))
|
||
mid = _hsl_to_hex(base_hue, random.uniform(0.40, 0.60), random.uniform(0.25, 0.35))
|
||
accent = _hsl_to_hex(accent_hue, random.uniform(0.30, 0.50), random.uniform(0.65, 0.80))
|
||
text = _hsl_to_hex(base_hue, 0.10, random.uniform(0.90, 0.96))
|
||
muted = _hsl_to_hex(base_hue, 0.20, random.uniform(0.60, 0.70))
|
||
surface = f"rgba(0,0,0,{random.uniform(0.20, 0.40):.2f})"
|
||
|
||
else:
|
||
# light — noticeably tinted, not dead white (S 0.10-0.20, L 0.91-0.95)
|
||
bg = _hsl_to_hex(base_hue, random.uniform(0.10, 0.20), random.uniform(0.91, 0.95))
|
||
mid = _hsl_to_hex(base_hue, random.uniform(0.15, 0.25), random.uniform(0.84, 0.90))
|
||
accent = _hsl_to_hex(accent_hue, random.uniform(0.35, 0.45), random.uniform(0.35, 0.45))
|
||
text = _hsl_to_hex(base_hue, 0.08, random.uniform(0.08, 0.15))
|
||
muted = _hsl_to_hex(base_hue, 0.05, random.uniform(0.45, 0.55))
|
||
surface = f"rgba(0,0,0,{random.uniform(0.02, 0.05):.2f})"
|
||
|
||
# 3. WCAG Contrast Safety Net — ensure text:bg ratio ≥ 4.5:1
|
||
text_cr = _contrast_ratio(text, bg)
|
||
if text_cr < 4.5:
|
||
# Push text darker (light modes) or lighter (dark modes)
|
||
r, g, b = (int(bg.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
||
_, bg_l, _ = colorsys.rgb_to_hls(r, g, b)
|
||
if bg_l > 0.5:
|
||
text = _hsl_to_hex(base_hue, 0.08, 0.08) # force near-black
|
||
else:
|
||
text = _hsl_to_hex(base_hue, 0.05, 0.95) # force near-white
|
||
|
||
# 4. Accent-on-bg visibility check — ensure accent stands out (ratio ≥ 3:1)
|
||
accent_cr = _contrast_ratio(accent, bg)
|
||
if accent_cr < 3.0:
|
||
# Nudge accent lightness away from bg
|
||
r, g, b = (int(bg.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
||
_, bg_l, _ = colorsys.rgb_to_hls(r, g, b)
|
||
target_l = 0.35 if bg_l > 0.5 else 0.70
|
||
r2, g2, b2 = (int(accent.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
||
_, _, accent_s = colorsys.rgb_to_hls(r2, g2, b2)
|
||
accent = _hsl_to_hex(accent_hue, accent_s, target_l)
|
||
|
||
return {
|
||
"bg": bg, "bg_rgb": _hex_to_rgb(bg),
|
||
"mid": mid, "mid_rgb": _hex_to_rgb(mid),
|
||
"accent": accent, "accent_rgb": _hex_to_rgb(accent),
|
||
"text": text, "muted": muted,
|
||
"surface": surface,
|
||
"meta": {
|
||
"intent": intent, "mode": mode, "harmony": harmony,
|
||
"base_hue": base_hue, "accent_hue": accent_hue,
|
||
"contrast": {
|
||
"text_on_bg": round(_contrast_ratio(text, bg), 2),
|
||
"accent_on_bg": round(_contrast_ratio(accent, bg), 2),
|
||
}
|
||
}
|
||
}
|
||
|
||
def palette_to_css(palette):
|
||
"""Convert palette dict to CSS custom properties."""
|
||
return f""":root {{
|
||
/* 60% ground */
|
||
--c-bg: {palette['bg']};
|
||
--c-bg-rgb: {palette['bg_rgb']};
|
||
/* 30% structure */
|
||
--c-mid: {palette['mid']};
|
||
--c-mid-rgb: {palette['mid_rgb']};
|
||
/* 10% emphasis */
|
||
--c-accent: {palette['accent']};
|
||
--c-accent-rgb: {palette['accent_rgb']};
|
||
/* Typography */
|
||
--c-text: {palette['text']};
|
||
--c-muted: {palette['muted']};
|
||
/* Surfaces */
|
||
--c-surface: {palette['surface']};
|
||
}}"""
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# 1b. CASCADE PALETTE — Role-Based Unified Color System
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
#
|
||
# Iron Law: Area ∝ 1/Saturation.
|
||
# The larger the colored area, the LOWER its saturation must be.
|
||
#
|
||
# Role tiers by area usage:
|
||
# XL (>50% of page): page_bg, section_bg → S ≤ 0.08, near-white/near-black
|
||
# L (20-50%): card_bg, table_stripe → S ≤ 0.15
|
||
# M (5-20%): header_fill, sidebar → S ≤ 0.30
|
||
# S (1-5%): border, divider, icon → S ≤ 0.50
|
||
# XS (<1%): accent_dot, badge, tag → S up to 0.75 (the only place high-sat lives)
|
||
#
|
||
# Every color is derived from one base hue. No orphan colors.
|
||
|
||
# Tier saturation caps — enforced at generation AND audit time
|
||
CASCADE_TIER_CAPS = {
|
||
"xl": 0.08, # Page background, section background
|
||
"l": 0.15, # Card background, table stripe
|
||
"m": 0.30, # Header fill, sidebar, cover decorative blocks
|
||
"s": 0.50, # Borders, dividers, icons, chart grid lines
|
||
"xs": 0.75, # Accent dot, badge, tag, data point highlight
|
||
}
|
||
|
||
|
||
def generate_cascade_palette(intent="neutral", mode="minimal", harmony=None, seed=None):
|
||
"""
|
||
Generate a role-based palette cascade where every color is derived from one
|
||
base hue and saturation is inversely proportional to usage area.
|
||
|
||
Returns a dict with:
|
||
roles: Complete role table (12 roles, each with hex, hsl, tier, usage_hint)
|
||
cover: Subset of roles for cover page rendering
|
||
body: Subset of roles for body content
|
||
charts: Subset of roles for data visualization
|
||
semantic: Low-saturation semantic colors (success, warning, error, info)
|
||
meta: Generation metadata (intent, mode, harmony, base_hue, audit)
|
||
css: Ready-to-use CSS custom properties
|
||
reportlab: Ready-to-paste ReportLab Python code
|
||
"""
|
||
if seed is not None:
|
||
random.seed(seed)
|
||
|
||
if harmony is None or harmony == "auto":
|
||
harmony = INTENT_HARMONY_MAP.get(intent, "split_complementary")
|
||
|
||
base_hue = INTENT_HUES.get(intent, random.randint(0, 359))
|
||
|
||
# Geometric accent hue derivation (same logic as original)
|
||
if harmony == "complementary":
|
||
accent_hue = (base_hue + 180) % 360
|
||
elif harmony == "split_complementary":
|
||
accent_hue = (base_hue + random.choice([150, 210])) % 360
|
||
elif harmony == "triadic":
|
||
accent_hue = (base_hue + random.choice([120, 240])) % 360
|
||
elif harmony == "analogous":
|
||
accent_hue = (base_hue + random.choice([30, -30, 45, -45])) % 360
|
||
else: # monochrome
|
||
accent_hue = base_hue
|
||
|
||
# Sanitize accent hue — avoid muddy zones
|
||
accent_hue = _sanitize_accent_hue(accent_hue)
|
||
|
||
# Secondary hue — between base and accent, for chart variety
|
||
secondary_hue = _sanitize_accent_hue(
|
||
(base_hue + accent_hue) / 2 if accent_hue != base_hue else (base_hue + 30) % 360
|
||
)
|
||
|
||
# Mode-dependent lightness anchors
|
||
is_dark = mode == "dark"
|
||
if is_dark:
|
||
bg_l_range = (0.04, 0.08)
|
||
text_l = random.uniform(0.88, 0.95)
|
||
muted_l = random.uniform(0.50, 0.60)
|
||
elif mode == "jewel":
|
||
bg_l_range = (0.15, 0.25)
|
||
text_l = random.uniform(0.90, 0.96)
|
||
muted_l = random.uniform(0.60, 0.70)
|
||
else: # minimal, pastel, light
|
||
bg_l_range = (0.94, 0.97)
|
||
text_l = random.uniform(0.08, 0.15)
|
||
muted_l = random.uniform(0.45, 0.55)
|
||
|
||
# ── Generate all 12 roles ──
|
||
roles = {}
|
||
|
||
# XL tier: page bg, section bg
|
||
roles["page_bg"] = _make_role(
|
||
base_hue, random.uniform(0.03, 0.08), random.uniform(*bg_l_range),
|
||
"xl", "Page background, full-bleed areas")
|
||
roles["section_bg"] = _make_role(
|
||
base_hue, random.uniform(0.04, 0.08),
|
||
random.uniform(bg_l_range[0] - 0.03, bg_l_range[1] - 0.02) if not is_dark
|
||
else random.uniform(0.08, 0.14),
|
||
"xl", "Section background, alternating bands")
|
||
|
||
# L tier: card bg, table stripe
|
||
roles["card_bg"] = _make_role(
|
||
base_hue, random.uniform(0.06, 0.14),
|
||
random.uniform(0.90, 0.94) if not is_dark else random.uniform(0.10, 0.16),
|
||
"l", "Card/container background, table even rows")
|
||
roles["table_stripe"] = _make_role(
|
||
base_hue, random.uniform(0.05, 0.12),
|
||
random.uniform(0.92, 0.96) if not is_dark else random.uniform(0.08, 0.12),
|
||
"l", "Table alternating row fill")
|
||
|
||
# M tier: header fill, sidebar, cover decorative block
|
||
roles["header_fill"] = _make_role(
|
||
base_hue, random.uniform(0.15, 0.28),
|
||
random.uniform(0.25, 0.40) if not is_dark else random.uniform(0.20, 0.30),
|
||
"m", "Table header, sidebar background, cover top bar")
|
||
roles["cover_block"] = _make_role(
|
||
base_hue, random.uniform(0.12, 0.25),
|
||
random.uniform(0.30, 0.45) if not is_dark else random.uniform(0.15, 0.25),
|
||
"m", "Cover decorative block, sidebar pillar")
|
||
|
||
# S tier: border, divider, icon fill
|
||
roles["border"] = _make_role(
|
||
base_hue, random.uniform(0.10, 0.25),
|
||
random.uniform(0.70, 0.82) if not is_dark else random.uniform(0.25, 0.35),
|
||
"s", "Borders, divider lines, chart grid")
|
||
roles["icon"] = _make_role(
|
||
base_hue, random.uniform(0.25, 0.45),
|
||
random.uniform(0.35, 0.50) if not is_dark else random.uniform(0.55, 0.70),
|
||
"s", "Icons, bullet points, small UI elements")
|
||
|
||
# XS tier: accent, badge, data highlight
|
||
roles["accent"] = _make_role(
|
||
accent_hue, random.uniform(0.50, 0.70),
|
||
random.uniform(0.40, 0.55) if not is_dark else random.uniform(0.55, 0.70),
|
||
"xs", "Primary accent: badges, tags, data point highlights, CTA")
|
||
roles["accent_secondary"] = _make_role(
|
||
secondary_hue, random.uniform(0.40, 0.60),
|
||
random.uniform(0.45, 0.58) if not is_dark else random.uniform(0.50, 0.65),
|
||
"xs", "Secondary accent: chart series 2, secondary badge")
|
||
|
||
# Typography (no tier — text is special)
|
||
roles["text_primary"] = _make_role(
|
||
base_hue, 0.05, text_l,
|
||
"text", "Primary body text")
|
||
roles["text_muted"] = _make_role(
|
||
base_hue, 0.04, muted_l,
|
||
"text", "Captions, footnotes, secondary text")
|
||
|
||
# ── Semantic colors (derived from base hue, low-sat) ──
|
||
semantic = {
|
||
"success": _make_role(140, random.uniform(0.25, 0.40),
|
||
random.uniform(0.35, 0.45) if not is_dark else random.uniform(0.55, 0.65),
|
||
"xs", "Positive: growth, pass, complete"),
|
||
"warning": _make_role(40, random.uniform(0.30, 0.45),
|
||
random.uniform(0.40, 0.50) if not is_dark else random.uniform(0.55, 0.65),
|
||
"xs", "Caution: pending, alert"),
|
||
"error": _make_role(5, random.uniform(0.30, 0.45),
|
||
random.uniform(0.40, 0.50) if not is_dark else random.uniform(0.55, 0.65),
|
||
"xs", "Negative: decline, fail, error"),
|
||
"info": _make_role(210, random.uniform(0.25, 0.40),
|
||
random.uniform(0.40, 0.50) if not is_dark else random.uniform(0.55, 0.65),
|
||
"xs", "Informational: neutral status"),
|
||
}
|
||
|
||
# ── WCAG contrast enforcement ──
|
||
page_bg_hex = roles["page_bg"]["hex"]
|
||
text_hex = roles["text_primary"]["hex"]
|
||
if _contrast_ratio(text_hex, page_bg_hex) < 4.5:
|
||
if not is_dark:
|
||
roles["text_primary"] = _make_role(base_hue, 0.08, 0.08, "text", "Primary body text")
|
||
else:
|
||
roles["text_primary"] = _make_role(base_hue, 0.05, 0.95, "text", "Primary body text")
|
||
|
||
accent_hex = roles["accent"]["hex"]
|
||
if _contrast_ratio(accent_hex, page_bg_hex) < 3.0:
|
||
# Push accent lightness further from bg
|
||
r, g, b = (int(page_bg_hex.lstrip('#')[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
||
_, bg_l, _ = colorsys.rgb_to_hls(r, g, b)
|
||
target_l = 0.35 if bg_l > 0.5 else 0.75
|
||
roles["accent"] = _make_role(accent_hue, random.uniform(0.50, 0.70), target_l,
|
||
"xs", "Primary accent: badges, tags, data point highlights, CTA")
|
||
|
||
# ── Tier saturation audit (clamp any violations) ──
|
||
for name, role in {**roles, **semantic}.items():
|
||
tier = role["tier"]
|
||
if tier in CASCADE_TIER_CAPS:
|
||
cap = CASCADE_TIER_CAPS[tier]
|
||
if role["hsl"][1] > cap:
|
||
# Re-generate with capped saturation
|
||
capped_s = cap * random.uniform(0.85, 1.0)
|
||
fixed = _make_role(role["hsl"][0], capped_s, role["hsl"][2],
|
||
tier, role["usage_hint"])
|
||
if name in roles:
|
||
roles[name] = fixed
|
||
elif name in semantic:
|
||
semantic[name] = fixed
|
||
|
||
# ── Build convenience subsets ──
|
||
cover_subset = {
|
||
"background": roles["page_bg"]["hex"],
|
||
"top_bar": roles["header_fill"]["hex"],
|
||
"sidebar": roles["cover_block"]["hex"],
|
||
"accent_line": roles["accent"]["hex"],
|
||
"title": roles["text_primary"]["hex"],
|
||
"subtitle": roles["text_muted"]["hex"],
|
||
"watermark": roles["border"]["hex"], # low-opacity usage
|
||
}
|
||
|
||
body_subset = {
|
||
"page_bg": roles["page_bg"]["hex"],
|
||
"section_bg": roles["section_bg"]["hex"],
|
||
"card_bg": roles["card_bg"]["hex"],
|
||
"table_header": roles["header_fill"]["hex"],
|
||
"table_stripe": roles["table_stripe"]["hex"],
|
||
"border": roles["border"]["hex"],
|
||
"heading": roles["text_primary"]["hex"],
|
||
"body_text": roles["text_primary"]["hex"],
|
||
"caption": roles["text_muted"]["hex"],
|
||
"highlight": roles["accent"]["hex"],
|
||
}
|
||
|
||
chart_subset = {
|
||
"series_1": roles["accent"]["hex"],
|
||
"series_2": roles["accent_secondary"]["hex"],
|
||
"series_3": roles["header_fill"]["hex"],
|
||
"series_4": roles["icon"]["hex"],
|
||
"series_5": roles["cover_block"]["hex"],
|
||
"grid": roles["border"]["hex"],
|
||
"axis_text": roles["text_muted"]["hex"],
|
||
"label": roles["text_primary"]["hex"],
|
||
"up": semantic["success"]["hex"],
|
||
"down": semantic["error"]["hex"],
|
||
}
|
||
|
||
# ── Generate outputs ──
|
||
css = _cascade_to_css(roles, semantic)
|
||
reportlab_code = _cascade_to_reportlab(roles, semantic)
|
||
|
||
audit_results = audit_cascade_palette(roles, semantic)
|
||
|
||
return {
|
||
"roles": {k: {"hex": v["hex"], "hsl": v["hsl"], "tier": v["tier"], "usage": v["usage_hint"]} for k, v in roles.items()},
|
||
"cover": cover_subset,
|
||
"body": body_subset,
|
||
"charts": chart_subset,
|
||
"semantic": {k: {"hex": v["hex"], "hsl": v["hsl"]} for k, v in semantic.items()},
|
||
"meta": {
|
||
"intent": intent, "mode": mode, "harmony": harmony,
|
||
"base_hue": base_hue, "accent_hue": accent_hue, "secondary_hue": secondary_hue,
|
||
"contrast": {
|
||
"text_on_bg": round(_contrast_ratio(roles["text_primary"]["hex"], roles["page_bg"]["hex"]), 2),
|
||
"accent_on_bg": round(_contrast_ratio(roles["accent"]["hex"], roles["page_bg"]["hex"]), 2),
|
||
},
|
||
"audit": audit_results,
|
||
},
|
||
"css": css,
|
||
"reportlab": reportlab_code,
|
||
}
|
||
|
||
|
||
def _make_role(h, s, l, tier, usage_hint):
|
||
"""Create a role entry with hex, HSL tuple, tier, and usage hint."""
|
||
hex_val = _hsl_to_hex(h, s, l)
|
||
return {
|
||
"hex": hex_val,
|
||
"hsl": (round(h, 1), round(s, 3), round(l, 3)),
|
||
"tier": tier,
|
||
"usage_hint": usage_hint,
|
||
}
|
||
|
||
|
||
def _cascade_to_css(roles, semantic):
|
||
"""Convert cascade palette to CSS custom properties."""
|
||
lines = [":root {"]
|
||
lines.append(" /* ── XL tier: backgrounds (S ≤ 0.08) ── */")
|
||
lines.append(f" --page-bg: {roles['page_bg']['hex']};")
|
||
lines.append(f" --section-bg: {roles['section_bg']['hex']};")
|
||
lines.append(" /* ── L tier: surfaces (S ≤ 0.15) ── */")
|
||
lines.append(f" --card-bg: {roles['card_bg']['hex']};")
|
||
lines.append(f" --table-stripe: {roles['table_stripe']['hex']};")
|
||
lines.append(" /* ── M tier: structural fills (S ≤ 0.30) ── */")
|
||
lines.append(f" --header-fill: {roles['header_fill']['hex']};")
|
||
lines.append(f" --cover-block: {roles['cover_block']['hex']};")
|
||
lines.append(" /* ── S tier: edges & icons (S ≤ 0.50) ── */")
|
||
lines.append(f" --border: {roles['border']['hex']};")
|
||
lines.append(f" --icon: {roles['icon']['hex']};")
|
||
lines.append(" /* ── XS tier: emphasis (S ≤ 0.75) ── */")
|
||
lines.append(f" --accent: {roles['accent']['hex']};")
|
||
lines.append(f" --accent-secondary: {roles['accent_secondary']['hex']};")
|
||
lines.append(" /* ── Typography ── */")
|
||
lines.append(f" --text-primary: {roles['text_primary']['hex']};")
|
||
lines.append(f" --text-muted: {roles['text_muted']['hex']};")
|
||
lines.append(" /* ── Semantic (low-sat) ── */")
|
||
for k, v in semantic.items():
|
||
lines.append(f" --semantic-{k}: {v['hex']};")
|
||
lines.append("}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _cascade_to_reportlab(roles, semantic):
|
||
"""Convert cascade palette to ReportLab Python code."""
|
||
lines = [
|
||
"# ━━ Cascade Palette (auto-generated by design_engine.py palette-cascade) ━━",
|
||
"from reportlab.lib import colors",
|
||
"",
|
||
"# XL tier: backgrounds (area > 50%, S ≤ 0.08)",
|
||
f"PAGE_BG = colors.HexColor('{roles['page_bg']['hex']}')",
|
||
f"SECTION_BG = colors.HexColor('{roles['section_bg']['hex']}')",
|
||
"",
|
||
"# L tier: surfaces (area 20-50%, S ≤ 0.15)",
|
||
f"CARD_BG = colors.HexColor('{roles['card_bg']['hex']}')",
|
||
f"TABLE_STRIPE = colors.HexColor('{roles['table_stripe']['hex']}')",
|
||
"",
|
||
"# M tier: structural fills (area 5-20%, S ≤ 0.30)",
|
||
f"HEADER_FILL = colors.HexColor('{roles['header_fill']['hex']}')",
|
||
f"COVER_BLOCK = colors.HexColor('{roles['cover_block']['hex']}')",
|
||
"",
|
||
"# S tier: edges & icons (area 1-5%, S ≤ 0.50)",
|
||
f"BORDER = colors.HexColor('{roles['border']['hex']}')",
|
||
f"ICON = colors.HexColor('{roles['icon']['hex']}')",
|
||
"",
|
||
"# XS tier: emphasis (area < 1%, S ≤ 0.75)",
|
||
f"ACCENT = colors.HexColor('{roles['accent']['hex']}')",
|
||
f"ACCENT_2 = colors.HexColor('{roles['accent_secondary']['hex']}')",
|
||
"",
|
||
"# Typography",
|
||
f"TEXT_PRIMARY = colors.HexColor('{roles['text_primary']['hex']}')",
|
||
f"TEXT_MUTED = colors.HexColor('{roles['text_muted']['hex']}')",
|
||
"",
|
||
"# Semantic (low-saturation, area-appropriate)",
|
||
f"SEM_SUCCESS = colors.HexColor('{semantic['success']['hex']}')",
|
||
f"SEM_WARNING = colors.HexColor('{semantic['warning']['hex']}')",
|
||
f"SEM_ERROR = colors.HexColor('{semantic['error']['hex']}')",
|
||
f"SEM_INFO = colors.HexColor('{semantic['info']['hex']}')",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def audit_cascade_palette(roles, semantic):
|
||
"""Audit cascade palette for tier saturation violations and WCAG contrast."""
|
||
violations = []
|
||
for name, role in {**roles, **semantic}.items():
|
||
tier = role["tier"]
|
||
if tier in CASCADE_TIER_CAPS:
|
||
cap = CASCADE_TIER_CAPS[tier]
|
||
s = role["hsl"][1]
|
||
if s > cap:
|
||
violations.append(f"{name}: S={s:.3f} exceeds tier '{tier}' cap {cap}")
|
||
|
||
# WCAG checks
|
||
bg_hex = roles["page_bg"]["hex"]
|
||
text_hex = roles["text_primary"]["hex"]
|
||
cr = _contrast_ratio(text_hex, bg_hex)
|
||
if cr < 4.5:
|
||
violations.append(f"text_primary:page_bg contrast {cr:.2f} < 4.5:1 (WCAG AA)")
|
||
|
||
accent_hex = roles["accent"]["hex"]
|
||
cr_a = _contrast_ratio(accent_hex, bg_hex)
|
||
if cr_a < 3.0:
|
||
violations.append(f"accent:page_bg contrast {cr_a:.2f} < 3.0:1 (accent invisible)")
|
||
|
||
# Header fill should be readable with white text on it
|
||
hf_hex = roles["header_fill"]["hex"]
|
||
cr_hf = _contrast_ratio("#ffffff", hf_hex)
|
||
if cr_hf < 3.0:
|
||
violations.append(f"white on header_fill contrast {cr_hf:.2f} < 3.0:1 (header text unreadable)")
|
||
|
||
return violations
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# 2. GENERATIVE SVG — Algorithmic Art Backgrounds
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
#
|
||
# Two primary modes:
|
||
# - "flow": 3-5 large-radius bézier curves at ultra-low opacity (0.05)
|
||
# Creates organic, breathing atmospheric depth
|
||
# - "grid": 1px reference grid or noise texture
|
||
# Creates structured, architectural underlying rhythm
|
||
#
|
||
# All SVG is inline-ready (no external files needed).
|
||
|
||
def _svg_open(w, h):
|
||
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">'
|
||
|
||
def _random_bezier_path(w, h):
|
||
"""Generate a single flowing bézier curve across the canvas."""
|
||
# Start from a random edge point
|
||
x0 = random.uniform(-w * 0.2, w * 0.3)
|
||
y0 = random.uniform(0, h)
|
||
# End at the opposite side
|
||
x3 = random.uniform(w * 0.7, w * 1.2)
|
||
y3 = random.uniform(0, h)
|
||
# Control points — large sweeps for organic feel
|
||
cx1 = random.uniform(w * 0.1, w * 0.5)
|
||
cy1 = random.uniform(-h * 0.3, h * 1.3)
|
||
cx2 = random.uniform(w * 0.5, w * 0.9)
|
||
cy2 = random.uniform(-h * 0.3, h * 1.3)
|
||
return f"M{x0:.0f},{y0:.0f} C{cx1:.0f},{cy1:.0f} {cx2:.0f},{cy2:.0f} {x3:.0f},{y3:.0f}"
|
||
|
||
def generate_flow_svg(w, h, color="#8a8a8a", curves=4, stroke_width=80, opacity=0.05):
|
||
"""
|
||
Flow mode: ultra-wide, ultra-faint bézier curves.
|
||
Creates atmospheric depth without competing with content.
|
||
"""
|
||
random.seed(42)
|
||
svg = _svg_open(w, h)
|
||
svg += '\n <defs>'
|
||
svg += f'\n <linearGradient id="fg" x1="0" y1="0" x2="1" y2="1">'
|
||
svg += f'\n <stop offset="0%" stop-color="{color}" stop-opacity="{opacity}"/>'
|
||
svg += f'\n <stop offset="50%" stop-color="{color}" stop-opacity="{opacity * 1.5:.3f}"/>'
|
||
svg += f'\n <stop offset="100%" stop-color="{color}" stop-opacity="{opacity * 0.5:.3f}"/>'
|
||
svg += '\n </linearGradient>'
|
||
svg += '\n </defs>'
|
||
|
||
for i in range(curves):
|
||
path = _random_bezier_path(w, h)
|
||
sw = stroke_width + random.uniform(-20, 30)
|
||
svg += f'\n <path d="{path}" fill="none" stroke="url(#fg)" '
|
||
svg += f'stroke-width="{sw:.0f}" stroke-linecap="round" opacity="{opacity + i * 0.01:.3f}"/>'
|
||
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
def generate_grid_svg(w, h, color="#888888", spacing=60, line_width=0.5, opacity=0.04):
|
||
"""
|
||
Grid mode: architectural reference grid.
|
||
Ultra-faint 1px lines creating underlying structure.
|
||
"""
|
||
svg = _svg_open(w, h)
|
||
svg += f'\n <g opacity="{opacity}">'
|
||
# Vertical lines
|
||
for x in range(0, int(w) + 1, spacing):
|
||
svg += f'\n <line x1="{x}" y1="0" x2="{x}" y2="{h}" stroke="{color}" stroke-width="{line_width}"/>'
|
||
# Horizontal lines
|
||
for y in range(0, int(h) + 1, spacing):
|
||
svg += f'\n <line x1="0" y1="{y}" x2="{w}" y2="{y}" stroke="{color}" stroke-width="{line_width}"/>'
|
||
svg += '\n </g>'
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
def generate_noise_svg(w, h, frequency=0.8, octaves=4, opacity=0.035):
|
||
"""
|
||
Noise mode: feTurbulence grain texture.
|
||
Adds tactile paper-like quality.
|
||
"""
|
||
svg = _svg_open(w, h)
|
||
svg += f"""
|
||
<defs>
|
||
<filter id="grain" x="0" y="0" width="100%" height="100%">
|
||
<feTurbulence type="fractalNoise" baseFrequency="{frequency}"
|
||
numOctaves="{octaves}" stitchTiles="stitch" result="noise"/>
|
||
<feColorMatrix type="saturate" values="0" in="noise" result="grey"/>
|
||
</filter>
|
||
</defs>
|
||
<rect width="{w}" height="{h}" filter="url(#grain)" opacity="{opacity}"/>"""
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
def _generate_data_driven_svg(data_points, w=720, h=960, color="#8a8a8a"):
|
||
"""
|
||
Content-Aware SVG: Transform business data arrays into Bézier background curves.
|
||
The background subtly echoes the data's shape — a cross-modal design metaphor.
|
||
"""
|
||
if not data_points or len(data_points) < 2:
|
||
return generate_flow_svg(w, h, color)
|
||
|
||
n = len(data_points)
|
||
min_v = min(data_points)
|
||
max_v = max(data_points)
|
||
rng = max_v - min_v if max_v != min_v else 1.0
|
||
|
||
# Normalize data to canvas coordinates
|
||
# X: evenly spaced across width; Y: mapped to 20%-80% of height (inverted for SVG)
|
||
points = []
|
||
for i, v in enumerate(data_points):
|
||
x = (i / (n - 1)) * w
|
||
y_norm = (v - min_v) / rng # 0..1
|
||
y = h * 0.8 - y_norm * (h * 0.6) # Map to 20%-80% height band
|
||
points.append((x, y))
|
||
|
||
# Build smooth Bézier path through all points (Catmull-Rom → cubic Bézier)
|
||
def catmull_to_bezier(p0, p1, p2, p3, tension=0.5):
|
||
"""Convert 4 Catmull-Rom points to cubic Bézier control points for segment p1→p2."""
|
||
cp1x = p1[0] + (p2[0] - p0[0]) / (6 * tension)
|
||
cp1y = p1[1] + (p2[1] - p0[1]) / (6 * tension)
|
||
cp2x = p2[0] - (p3[0] - p1[0]) / (6 * tension)
|
||
cp2y = p2[1] - (p3[1] - p1[1]) / (6 * tension)
|
||
return (cp1x, cp1y), (cp2x, cp2y)
|
||
|
||
svg_paths = ""
|
||
# Generate 3 layers at different offsets for depth
|
||
for layer_idx, (opacity, y_offset, thickness) in enumerate([
|
||
(0.08, 0, 3), (0.05, 60, 2), (0.03, -40, 1.5)
|
||
]):
|
||
pts = [(px, py + y_offset) for px, py in points]
|
||
# Pad start/end for Catmull-Rom
|
||
padded = [pts[0]] + pts + [pts[-1]]
|
||
d = f"M {padded[1][0]:.1f},{padded[1][1]:.1f} "
|
||
for i in range(1, len(padded) - 2):
|
||
cp1, cp2 = catmull_to_bezier(padded[i-1], padded[i], padded[i+1], padded[i+2])
|
||
d += f"C {cp1[0]:.1f},{cp1[1]:.1f} {cp2[0]:.1f},{cp2[1]:.1f} {padded[i+1][0]:.1f},{padded[i+1][1]:.1f} "
|
||
|
||
# Main stroke
|
||
svg_paths += f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{thickness}" opacity="{opacity}" />\n'
|
||
# Filled area below the curve
|
||
fill_d = d + f"L {w},{h} L 0,{h} Z"
|
||
svg_paths += f'<path d="{fill_d}" fill="{color}" opacity="{opacity * 0.4}" />\n'
|
||
|
||
return f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%">{svg_paths}</svg>'
|
||
|
||
|
||
def generate_supergraphic_svg(w, h, color="#8a8a8a", seed=42):
|
||
"""
|
||
Supergraphic mode: oversized geometric shapes (circles, rectangles, polygons)
|
||
cropped by the canvas edge, rendered at 3-5% opacity.
|
||
Creates "blueprint of a larger world" feeling — McKinsey / Pentagram style.
|
||
Shapes deliberately overflow the viewport so only partial arcs/edges are visible.
|
||
"""
|
||
random.seed(seed)
|
||
svg = _svg_open(w, h)
|
||
# Layer 1: Giant concentric circles, center placed off-canvas
|
||
cx = random.uniform(w * 0.6, w * 1.3) # right-biased, partially off-canvas
|
||
cy = random.uniform(-h * 0.2, h * 0.4) # top-biased
|
||
for i in range(4):
|
||
r = w * (0.6 + i * 0.35) # radii from 60% to 165% of width — always overflowing
|
||
opacity = 0.04 - i * 0.008 # outer rings fainter
|
||
svg += f'\n <circle cx="{cx:.0f}" cy="{cy:.0f}" r="{r:.0f}" '
|
||
svg += f'fill="none" stroke="{color}" stroke-width="1.5" opacity="{max(opacity, 0.015):.3f}"/>'
|
||
|
||
# Layer 2: Oversized rotated rectangles, clipped by viewport
|
||
for _ in range(2):
|
||
rx = random.uniform(-w * 0.3, w * 0.5)
|
||
ry = random.uniform(h * 0.3, h * 1.2)
|
||
rw = random.uniform(w * 0.8, w * 1.6)
|
||
rh = random.uniform(h * 0.5, h * 1.2)
|
||
angle = random.uniform(-15, 25)
|
||
svg += f'\n <rect x="{rx:.0f}" y="{ry:.0f}" width="{rw:.0f}" height="{rh:.0f}" '
|
||
svg += f'fill="none" stroke="{color}" stroke-width="1" opacity="0.025" '
|
||
svg += f'transform="rotate({angle:.1f} {rx + rw/2:.0f} {ry + rh/2:.0f})"/>'
|
||
|
||
# Layer 3: A single massive polygon (pentagon/hexagon) barely visible
|
||
sides = random.choice([5, 6, 8])
|
||
pcx = random.uniform(-w * 0.1, w * 0.4)
|
||
pcy = random.uniform(h * 0.5, h * 1.1)
|
||
pr = w * random.uniform(0.9, 1.4)
|
||
angle_offset = random.uniform(0, 360 / sides)
|
||
points = []
|
||
for i in range(sides):
|
||
a = math.radians(angle_offset + i * 360 / sides)
|
||
px = pcx + pr * math.cos(a)
|
||
py = pcy + pr * math.sin(a)
|
||
points.append(f"{px:.0f},{py:.0f}")
|
||
svg += f'\n <polygon points="{" ".join(points)}" '
|
||
svg += f'fill="none" stroke="{color}" stroke-width="1" opacity="0.02"/>'
|
||
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
|
||
def generate_ordered_texture_svg(w, h, color="#8a8a8a", seed=42):
|
||
"""
|
||
Ordered Texture mode: precision dot-matrix, coordinate grids, and contour lines
|
||
placed in specific corners/edges of the canvas (not full coverage).
|
||
Creates "engineered precision" feeling — ideal for tech, finance, data reports.
|
||
"""
|
||
random.seed(seed)
|
||
svg = _svg_open(w, h)
|
||
|
||
# Region 1: Dot matrix in top-right corner (10x10 grid of small circles)
|
||
dot_cols, dot_rows = 12, 10
|
||
dot_spacing = 18
|
||
dot_r = 2
|
||
dot_origin_x = w - dot_cols * dot_spacing - 40 # right-aligned with margin
|
||
dot_origin_y = 30 # top margin
|
||
svg += f'\n <g opacity="0.08">'
|
||
for row in range(dot_rows):
|
||
for col in range(dot_cols):
|
||
dx = dot_origin_x + col * dot_spacing
|
||
dy = dot_origin_y + row * dot_spacing
|
||
svg += f'\n <circle cx="{dx}" cy="{dy}" r="{dot_r}" fill="{color}"/>'
|
||
svg += '\n </g>'
|
||
|
||
# Region 2: Coordinate grid lines in bottom-left quadrant (blueprint style)
|
||
grid_x0, grid_y0 = 20, h * 0.7
|
||
grid_x1, grid_y1 = w * 0.45, h - 20
|
||
grid_spacing = 30
|
||
svg += f'\n <g opacity="0.05" stroke="{color}" stroke-width="0.5" stroke-dasharray="4,6">'
|
||
# Vertical lines
|
||
x = grid_x0
|
||
while x <= grid_x1:
|
||
svg += f'\n <line x1="{x:.0f}" y1="{grid_y0:.0f}" x2="{x:.0f}" y2="{grid_y1:.0f}"/>'
|
||
x += grid_spacing
|
||
# Horizontal lines
|
||
y = grid_y0
|
||
while y <= grid_y1:
|
||
svg += f'\n <line x1="{grid_x0:.0f}" y1="{y:.0f}" x2="{grid_x1:.0f}" y2="{y:.0f}"/>'
|
||
y += grid_spacing
|
||
svg += '\n </g>'
|
||
|
||
# Region 3: Tick marks along the grid (ruler effect)
|
||
svg += f'\n <g opacity="0.06" stroke="{color}" stroke-width="0.8">'
|
||
x = grid_x0
|
||
while x <= grid_x1:
|
||
svg += f'\n <line x1="{x:.0f}" y1="{grid_y1:.0f}" x2="{x:.0f}" y2="{grid_y1 + 5:.0f}"/>'
|
||
x += grid_spacing
|
||
svg += '\n </g>'
|
||
|
||
# Region 4: Flowing contour lines (Bézier) across mid-right area
|
||
svg += f'\n <g opacity="0.04" fill="none" stroke="{color}" stroke-width="1">'
|
||
for i in range(5):
|
||
y_base = h * 0.35 + i * 35
|
||
x_start = w * 0.55
|
||
x_end = w + 20 # overflow right edge
|
||
cx1 = x_start + (x_end - x_start) * 0.3 + random.uniform(-30, 30)
|
||
cy1 = y_base + random.uniform(-40, 40)
|
||
cx2 = x_start + (x_end - x_start) * 0.7 + random.uniform(-30, 30)
|
||
cy2 = y_base + random.uniform(-40, 40)
|
||
svg += f'\n <path d="M{x_start:.0f},{y_base:.0f} C{cx1:.0f},{cy1:.0f} {cx2:.0f},{cy2:.0f} {x_end:.0f},{y_base + random.uniform(-20, 20):.0f}"/>'
|
||
svg += '\n </g>'
|
||
|
||
# Region 5: Cross-hair markers at 2-3 strategic points
|
||
for _ in range(3):
|
||
mx = random.uniform(w * 0.15, w * 0.85)
|
||
my = random.uniform(h * 0.15, h * 0.85)
|
||
size = 8
|
||
svg += f'\n <g opacity="0.06" stroke="{color}" stroke-width="0.8">'
|
||
svg += f'\n <line x1="{mx - size}" y1="{my}" x2="{mx + size}" y2="{my}"/>'
|
||
svg += f'\n <line x1="{mx}" y1="{my - size}" x2="{mx}" y2="{my + size}"/>'
|
||
svg += f'\n <circle cx="{mx}" cy="{my}" r="{size * 0.6:.0f}" fill="none"/>'
|
||
svg += '\n </g>'
|
||
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
|
||
def generate_generative_svg(svg_type="flow", w=720, h=960, color="#8a8a8a"):
|
||
"""Route to the appropriate SVG generator."""
|
||
if svg_type == "flow":
|
||
return generate_flow_svg(w, h, color)
|
||
elif svg_type == "grid":
|
||
return generate_grid_svg(w, h, color)
|
||
elif svg_type == "noise":
|
||
return generate_noise_svg(w, h)
|
||
elif svg_type == "supergraphic":
|
||
return generate_supergraphic_svg(w, h, color)
|
||
elif svg_type == "ordered_texture":
|
||
return generate_ordered_texture_svg(w, h, color)
|
||
else:
|
||
# Default: flow + noise layered
|
||
flow = generate_flow_svg(w, h, color)
|
||
return flow
|
||
|
||
|
||
def generate_continuous_flow_svg(w, h, total_pages, color="#8a8a8a", curves=4, stroke_width=80, opacity=0.05):
|
||
"""
|
||
Continuous Flow mode: generates ONE large SVG spanning all pages.
|
||
Returns a list of per-page SVG strings, each using viewBox to slice the master.
|
||
|
||
The bezier curves are constrained to have anchor points every ~480px vertically,
|
||
ensuring visible content on every page.
|
||
"""
|
||
total_h = h * total_pages
|
||
random.seed(42)
|
||
|
||
# Build the master path data
|
||
paths_data = []
|
||
for _ in range(curves):
|
||
# Generate anchor points — one every 480px ensures ~2 per page
|
||
num_anchors = max(3, int(total_h / 480))
|
||
anchors = []
|
||
for i in range(num_anchors):
|
||
ax = random.uniform(w * 0.1, w * 0.9)
|
||
ay = (total_h / (num_anchors - 1)) * i
|
||
anchors.append((ax, ay))
|
||
|
||
# Build cubic bezier path through anchors
|
||
path = f"M{anchors[0][0]:.0f},{anchors[0][1]:.0f}"
|
||
for i in range(1, len(anchors)):
|
||
prev = anchors[i - 1]
|
||
curr = anchors[i]
|
||
# Control points: spread horizontally for organic feel
|
||
cx1 = random.uniform(w * 0.0, w * 1.0)
|
||
cy1 = prev[1] + (curr[1] - prev[1]) * 0.33 + random.uniform(-100, 100)
|
||
cx2 = random.uniform(w * 0.0, w * 1.0)
|
||
cy2 = prev[1] + (curr[1] - prev[1]) * 0.66 + random.uniform(-100, 100)
|
||
path += f" C{cx1:.0f},{cy1:.0f} {cx2:.0f},{cy2:.0f} {curr[0]:.0f},{curr[1]:.0f}"
|
||
|
||
sw = stroke_width + random.uniform(-20, 30)
|
||
paths_data.append((path, sw))
|
||
|
||
# Generate per-page SVG slices
|
||
page_svgs = []
|
||
gradient_def = f'''<defs>
|
||
<linearGradient id="fg" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stop-color="{color}" stop-opacity="{opacity}"/>
|
||
<stop offset="50%" stop-color="{color}" stop-opacity="{opacity * 1.5:.3f}"/>
|
||
<stop offset="100%" stop-color="{color}" stop-opacity="{opacity * 0.5:.3f}"/>
|
||
</linearGradient>
|
||
</defs>'''
|
||
|
||
for page_idx in range(total_pages):
|
||
vy = page_idx * h
|
||
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 {vy} {w} {h}" width="{w}" height="{h}">'
|
||
svg += f'\n {gradient_def}'
|
||
for i, (path, sw) in enumerate(paths_data):
|
||
svg += f'\n <path d="{path}" fill="none" stroke="url(#fg)" '
|
||
svg += f'stroke-width="{sw:.0f}" stroke-linecap="round" opacity="{opacity + i * 0.01:.3f}"/>'
|
||
svg += '\n</svg>'
|
||
page_svgs.append(svg)
|
||
|
||
return page_svgs
|
||
|
||
|
||
def generate_unified_svg(w, h, total_pages, svg_type, color="#8a8a8a", curves=4, stroke_width=80, opacity=0.05):
|
||
"""
|
||
Generate a single SVG that spans the full continuous canvas (w x h*total_pages).
|
||
Unlike generate_continuous_flow_svg which returns per-page slices,
|
||
this returns ONE svg string for the entire document.
|
||
Used by the continuous-canvas rendering mode.
|
||
"""
|
||
total_h = h * total_pages
|
||
random.seed(42)
|
||
|
||
if svg_type in ("continuous_flow", "flow"):
|
||
# Bezier curves spanning entire height
|
||
paths_data = []
|
||
for _ in range(curves):
|
||
num_anchors = max(3, int(total_h / 480))
|
||
anchors = []
|
||
for i in range(num_anchors):
|
||
ax = random.uniform(w * 0.1, w * 0.9)
|
||
ay = (total_h / (num_anchors - 1)) * i
|
||
anchors.append((ax, ay))
|
||
path = f"M{anchors[0][0]:.0f},{anchors[0][1]:.0f}"
|
||
for i in range(1, len(anchors)):
|
||
prev = anchors[i - 1]
|
||
curr = anchors[i]
|
||
cx1 = random.uniform(w * 0.0, w * 1.0)
|
||
cy1 = prev[1] + (curr[1] - prev[1]) * 0.33 + random.uniform(-100, 100)
|
||
cx2 = random.uniform(w * 0.0, w * 1.0)
|
||
cy2 = prev[1] + (curr[1] - prev[1]) * 0.66 + random.uniform(-100, 100)
|
||
path += f" C{cx1:.0f},{cy1:.0f} {cx2:.0f},{cy2:.0f} {curr[0]:.0f},{curr[1]:.0f}"
|
||
sw = stroke_width + random.uniform(-20, 30)
|
||
paths_data.append((path, sw))
|
||
|
||
gradient_def = f'''<defs>
|
||
<linearGradient id="fg" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stop-color="{color}" stop-opacity="{opacity}"/>
|
||
<stop offset="50%" stop-color="{color}" stop-opacity="{opacity * 1.5:.3f}"/>
|
||
<stop offset="100%" stop-color="{color}" stop-opacity="{opacity * 0.5:.3f}"/>
|
||
</linearGradient>
|
||
</defs>'''
|
||
|
||
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {total_h}" width="{w}" height="{total_h}">'
|
||
svg += f'\n {gradient_def}'
|
||
for i, (path, sw) in enumerate(paths_data):
|
||
svg += f'\n <path d="{path}" fill="none" stroke="url(#fg)" '
|
||
svg += f'stroke-width="{sw:.0f}" stroke-linecap="round" opacity="{opacity + i * 0.01:.3f}"/>'
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
elif svg_type == "grid":
|
||
# Grid pattern spanning full height
|
||
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {total_h}" width="{w}" height="{total_h}">'
|
||
spacing = 60
|
||
for x in range(0, w + 1, spacing):
|
||
svg += f'\n <line x1="{x}" y1="0" x2="{x}" y2="{total_h}" stroke="{color}" stroke-width="0.5" opacity="{opacity}"/>'
|
||
for y in range(0, int(total_h) + 1, spacing):
|
||
svg += f'\n <line x1="0" y1="{y}" x2="{w}" y2="{y}" stroke="{color}" stroke-width="0.5" opacity="{opacity}"/>'
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
elif svg_type == "noise":
|
||
# Noise dots spanning full height
|
||
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {total_h}" width="{w}" height="{total_h}">'
|
||
num_dots = int(200 * total_pages)
|
||
for _ in range(num_dots):
|
||
cx = random.uniform(0, w)
|
||
cy = random.uniform(0, total_h)
|
||
r = random.uniform(0.5, 2.5)
|
||
svg += f'\n <circle cx="{cx:.0f}" cy="{cy:.0f}" r="{r:.1f}" fill="{color}" opacity="{random.uniform(opacity * 0.3, opacity * 1.5):.3f}"/>'
|
||
svg += '\n</svg>'
|
||
return svg
|
||
|
||
return ""
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# 3. LAYOUT CALCULATOR — Deliberate Spatial Tension
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
#
|
||
# Returns absolute coordinates for elements with intentional:
|
||
# - Offset: elements not perfectly centered (art tension)
|
||
# - Overlap: controlled z-index collisions
|
||
# - Breathing margin: 15% minimum on all edges
|
||
|
||
BREATHING_MARGIN = 0.12 # 12% of canvas on each edge (was 15%, too tight for content-dense pages)
|
||
|
||
def calculate_layout(elements, w=720, h=960, style="offset"):
|
||
"""
|
||
Calculate positioned layout for named elements.
|
||
|
||
Args:
|
||
elements: list of element names (e.g. ["hero", "body", "meta", "footer"])
|
||
style: "offset" (deliberate asymmetry), "centered" (formal), "stacked" (vertical flow)
|
||
|
||
Returns:
|
||
dict mapping element names to {x, y, w, h, rotation} in pixels
|
||
"""
|
||
# Safe area (15% breathing margin on all sides)
|
||
safe_x = w * BREATHING_MARGIN
|
||
safe_y = h * BREATHING_MARGIN
|
||
safe_w = w * (1 - 2 * BREATHING_MARGIN)
|
||
safe_h = h * (1 - 2 * BREATHING_MARGIN)
|
||
|
||
layout = {}
|
||
|
||
if style == "offset":
|
||
# Asymmetric placement — elements shift left/right of center
|
||
regions = _divide_vertical(safe_x, safe_y, safe_w, safe_h, len(elements))
|
||
for i, name in enumerate(elements):
|
||
rx, ry, rw, rh = regions[i]
|
||
# Apply deliberate offset: odd elements shift left, even shift right
|
||
offset_x = rw * 0.08 * (-1 if i % 2 == 0 else 1)
|
||
layout[name] = {
|
||
"x": round(rx + offset_x, 1),
|
||
"y": round(ry, 1),
|
||
"w": round(rw * 0.85, 1), # Don't fill the full width
|
||
"h": round(rh * 0.85, 1),
|
||
"rotation": round(random.uniform(-1.5, 1.5), 2) if name != "body" else 0,
|
||
}
|
||
|
||
elif style == "centered":
|
||
# Formal centered — golden ratio vertical split
|
||
regions = _divide_vertical(safe_x, safe_y, safe_w, safe_h, len(elements))
|
||
for i, name in enumerate(elements):
|
||
rx, ry, rw, rh = regions[i]
|
||
layout[name] = {
|
||
"x": round(rx + (rw * 0.075), 1), # Slight horizontal centering margin
|
||
"y": round(ry, 1),
|
||
"w": round(rw * 0.85, 1),
|
||
"h": round(rh * 0.9, 1),
|
||
"rotation": 0,
|
||
}
|
||
|
||
elif style == "overlap":
|
||
# Controlled overlaps — elements bleed into each other's space
|
||
regions = _divide_vertical(safe_x, safe_y, safe_w, safe_h, len(elements))
|
||
for i, name in enumerate(elements):
|
||
rx, ry, rw, rh = regions[i]
|
||
overlap_y = rh * 0.15 if i > 0 else 0 # Pull up into previous region
|
||
layout[name] = {
|
||
"x": round(rx, 1),
|
||
"y": round(ry - overlap_y, 1),
|
||
"w": round(rw, 1),
|
||
"h": round(rh + overlap_y * 0.5, 1),
|
||
"rotation": 0,
|
||
"z_index": len(elements) - i, # Later elements on top
|
||
}
|
||
|
||
return layout
|
||
|
||
def _divide_vertical(x, y, w, h, n):
|
||
"""Divide a rectangle into n vertical bands with golden-ratio-inspired proportions."""
|
||
if n <= 0:
|
||
return []
|
||
if n == 1:
|
||
return [(x, y, w, h)]
|
||
|
||
# Weighted distribution: first element (hero) gets more space
|
||
weights = [1.618 if i == 0 else 1.0 for i in range(n)]
|
||
total = sum(weights)
|
||
|
||
regions = []
|
||
current_y = y
|
||
for i in range(n):
|
||
region_h = h * weights[i] / total
|
||
regions.append((x, current_y, w, region_h))
|
||
current_y += region_h
|
||
|
||
return regions
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# VALIDATION — Post-generation color audit
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def audit_palette(palette):
|
||
"""
|
||
Strict audit: mode-specific S/L bounds + WCAG contrast checks.
|
||
Returns list of violations (empty = clean).
|
||
"""
|
||
violations = []
|
||
mode = palette.get("meta", {}).get("mode", "minimal")
|
||
|
||
for key in ["bg", "mid", "accent", "text"]:
|
||
hex_val = palette.get(key, "")
|
||
if not hex_val.startswith("#"):
|
||
continue
|
||
r, g, b = (int(hex_val[i:i+2], 16) / 255.0 for i in (1, 3, 5))
|
||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||
|
||
# Tight bg/mid saturation limits per mode
|
||
if key in ("bg", "mid"):
|
||
limits = {"minimal": 0.20, "dark": 0.16, "pastel": 0.42, "jewel": 0.62, "light": 0.28}
|
||
cap = limits.get(mode, 0.15)
|
||
if s > cap:
|
||
violations.append(f"{key}: S={s:.3f} > {cap} in {mode} mode")
|
||
|
||
# Tight accent saturation — pastel/jewel reined in
|
||
if key == "accent":
|
||
accent_caps = {"minimal": 0.78, "dark": 0.62, "pastel": 0.52, "jewel": 0.52, "light": 0.48}
|
||
cap = accent_caps.get(mode, 0.60)
|
||
if s > cap:
|
||
violations.append(f"accent: S={s:.3f} > {cap} in {mode} mode")
|
||
|
||
# Lightness guardrails
|
||
if key == "bg":
|
||
if mode == "dark" and l > 0.10:
|
||
violations.append(f"bg L={l:.3f} > 0.10 in dark (muddy middle)")
|
||
elif mode == "minimal" and l < 0.90:
|
||
violations.append(f"bg L={l:.3f} < 0.90 in minimal (too dark for paper)")
|
||
elif mode == "light" and l < 0.88:
|
||
violations.append(f"bg L={l:.3f} < 0.88 in light (too dark)")
|
||
elif mode == "jewel" and l > 0.28:
|
||
violations.append(f"bg L={l:.3f} > 0.28 in jewel (not deep enough)")
|
||
elif mode == "pastel" and l < 0.83:
|
||
violations.append(f"bg L={l:.3f} < 0.83 in pastel (too dark for Morandi)")
|
||
|
||
# WCAG contrast checks
|
||
bg_hex = palette.get("bg", "")
|
||
text_hex = palette.get("text", "")
|
||
accent_hex = palette.get("accent", "")
|
||
if bg_hex.startswith("#") and text_hex.startswith("#"):
|
||
cr = _contrast_ratio(text_hex, bg_hex)
|
||
if cr < 4.5:
|
||
violations.append(f"text:bg contrast {cr:.2f} < 4.5:1 (WCAG AA fail)")
|
||
if bg_hex.startswith("#") and accent_hex.startswith("#"):
|
||
cr = _contrast_ratio(accent_hex, bg_hex)
|
||
if cr < 2.5:
|
||
violations.append(f"accent:bg contrast {cr:.2f} < 2.5:1 (accent invisible)")
|
||
|
||
return violations
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# CLI
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Design Engine — aesthetic computation for art-direction-first PDF",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
%(prog)s palette --intent serenity --mode dark
|
||
%(prog)s palette --intent tension --mode light --seed 42
|
||
%(prog)s palette-cascade --intent cold --mode minimal
|
||
%(prog)s palette-cascade --intent neutral --format reportlab
|
||
%(prog)s svg --intent flow --dimensions 720x960
|
||
%(prog)s svg --intent grid --dimensions 720x960
|
||
%(prog)s layout --elements hero,body,meta --dimensions 720x960 --style offset
|
||
%(prog)s full --intent serenity --mode dark --dimensions 720x960 --output-dir ./assets/
|
||
%(prog)s audit --css-file assets/palette.css
|
||
"""
|
||
)
|
||
sub = parser.add_subparsers(dest="command")
|
||
|
||
# palette
|
||
p_pal = sub.add_parser("palette", help="Generate HSL-locked color palette")
|
||
p_pal.add_argument("--intent", default="neutral", choices=list(INTENT_HUES.keys()))
|
||
p_pal.add_argument("--mode", default="minimal", choices=["minimal", "dark", "pastel", "jewel", "light"])
|
||
p_pal.add_argument("--harmony", default="auto", choices=["auto", "complementary", "split_complementary", "triadic", "analogous", "monochrome"])
|
||
p_pal.add_argument("--seed", type=int, default=None)
|
||
p_pal.add_argument("--format", default="css", choices=["css", "json"])
|
||
|
||
# svg
|
||
p_svg = sub.add_parser("svg", help="Generate algorithmic SVG background")
|
||
p_svg.add_argument("--svg-type", default="flow", choices=["flow", "grid", "noise", "supergraphic", "ordered_texture"])
|
||
p_svg.add_argument("--dimensions", default="720x960")
|
||
p_svg.add_argument("--color", default="#8a8a8a")
|
||
|
||
# layout
|
||
p_lay = sub.add_parser("layout", help="Calculate element positions")
|
||
p_lay.add_argument("--elements", default="hero,body,meta")
|
||
p_lay.add_argument("--dimensions", default="720x960")
|
||
p_lay.add_argument("--style", default="offset", choices=["offset", "centered", "overlap"])
|
||
|
||
# full
|
||
p_full = sub.add_parser("full", help="Generate all assets at once")
|
||
p_full.add_argument("--intent", default="neutral")
|
||
p_full.add_argument("--mode", default="minimal", choices=["minimal", "dark", "pastel", "jewel", "light"])
|
||
p_full.add_argument("--harmony", default="auto", choices=["auto", "complementary", "split_complementary", "triadic", "analogous", "monochrome"])
|
||
p_full.add_argument("--svg-intent", default="flow", choices=["flow", "grid", "noise"])
|
||
p_full.add_argument("--dimensions", default="720x960")
|
||
p_full.add_argument("--elements", default="hero,body,meta")
|
||
p_full.add_argument("--style", default="offset")
|
||
p_full.add_argument("--seed", type=int, default=None)
|
||
p_full.add_argument("--output-dir", default="./assets/")
|
||
|
||
# audit
|
||
p_audit = sub.add_parser("audit", help="Audit a palette for constraint violations")
|
||
p_audit.add_argument("--palette-json", required=True)
|
||
|
||
# palette-cascade
|
||
p_pcas = sub.add_parser("palette-cascade", help="Generate role-based cascade palette (area ∝ 1/saturation)")
|
||
p_pcas.add_argument("--intent", default="neutral", choices=list(INTENT_HUES.keys()))
|
||
p_pcas.add_argument("--mode", default="minimal", choices=["minimal", "dark", "pastel", "jewel", "light"])
|
||
p_pcas.add_argument("--harmony", default="auto", choices=["auto", "complementary", "split_complementary", "triadic", "analogous", "monochrome"])
|
||
p_pcas.add_argument("--seed", type=int, default=None)
|
||
p_pcas.add_argument("--format", default="summary", choices=["summary", "json", "css", "reportlab"])
|
||
|
||
# compile
|
||
p_compile = sub.add_parser("compile", help="Compile a JSON Blueprint into a final HTML document")
|
||
p_compile.add_argument("--blueprint", required=True, help="Path to the JSON blueprint generated by LLM")
|
||
p_compile.add_argument("--output", default="poster.html", help="Path to save the output HTML")
|
||
|
||
# derive
|
||
p_derive = sub.add_parser("derive", help="Auto-derive design intent from document description")
|
||
p_derive.add_argument("text", help="Document title or description")
|
||
|
||
# Backward compat: positional command
|
||
parser.add_argument("legacy_command", nargs="?")
|
||
parser.add_argument("legacy_args", nargs="*")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == "palette":
|
||
pal = generate_color_palette(args.intent, args.mode, harmony=args.harmony, seed=args.seed)
|
||
if args.format == "json":
|
||
print(json.dumps(pal, indent=2))
|
||
else:
|
||
print(palette_to_css(pal))
|
||
|
||
elif args.command == "svg":
|
||
w, h = map(int, args.dimensions.split("x"))
|
||
print(generate_generative_svg(args.svg_type, w, h, args.color))
|
||
|
||
elif args.command == "layout":
|
||
w, h = map(int, args.dimensions.split("x"))
|
||
elements = [e.strip() for e in args.elements.split(",")]
|
||
result = calculate_layout(elements, w, h, args.style)
|
||
print(json.dumps(result, indent=2))
|
||
|
||
elif args.command == "full":
|
||
w, h = map(int, args.dimensions.split("x"))
|
||
os.makedirs(args.output_dir, exist_ok=True)
|
||
|
||
# 1. Palette
|
||
pal = generate_color_palette(args.intent, args.mode, harmony=getattr(args, 'harmony', 'split_complementary'), seed=args.seed)
|
||
css = palette_to_css(pal)
|
||
css_path = os.path.join(args.output_dir, "palette.css")
|
||
with open(css_path, "w") as f:
|
||
f.write(css)
|
||
json_path = os.path.join(args.output_dir, "palette.json")
|
||
with open(json_path, "w") as f:
|
||
json.dump(pal, f, indent=2)
|
||
print(f"✅ {css_path}")
|
||
|
||
# 2. SVG
|
||
svg_color = pal["accent"] # Use accent for SVG strokes
|
||
svg = generate_generative_svg(args.svg_intent, w, h, svg_color)
|
||
svg_path = os.path.join(args.output_dir, "background.svg")
|
||
with open(svg_path, "w") as f:
|
||
f.write(svg)
|
||
print(f"✅ {svg_path}")
|
||
|
||
# 3. Layout
|
||
elements = [e.strip() for e in args.elements.split(",")]
|
||
lay = calculate_layout(elements, w, h, args.style)
|
||
lay_path = os.path.join(args.output_dir, "layout.json")
|
||
with open(lay_path, "w") as f:
|
||
json.dump(lay, f, indent=2)
|
||
print(f"✅ {lay_path}")
|
||
|
||
# 4. Audit
|
||
violations = audit_palette(pal)
|
||
if violations:
|
||
print(f"\n⚠️ Palette violations:")
|
||
for v in violations:
|
||
print(f" - {v}")
|
||
else:
|
||
print(f"\n✅ Palette passes all constraints")
|
||
|
||
print(f"\n🎨 {args.intent}/{args.mode} | {w}×{h} | {len(elements)} elements")
|
||
|
||
elif args.command == "audit":
|
||
with open(args.palette_json) as f:
|
||
pal = json.load(f)
|
||
violations = audit_palette(pal)
|
||
if violations:
|
||
print("⚠️ Violations found:")
|
||
for v in violations:
|
||
print(f" - {v}")
|
||
sys.exit(1)
|
||
else:
|
||
print("✅ Palette passes all constraints")
|
||
|
||
elif args.command == "derive":
|
||
intent = derive_intent(args.text)
|
||
print(f"Intent: {intent}")
|
||
print(f"Hue: {INTENT_HUES.get(intent, 45)}°")
|
||
# Also generate a quick palette preview
|
||
pal = generate_color_palette(intent, "dark")
|
||
print(f"Preview (dark): bg={pal['bg']} accent={pal['accent']}")
|
||
pal_light = generate_color_palette(intent, "light")
|
||
print(f"Preview (light): bg={pal_light['bg']} accent={pal_light['accent']}")
|
||
|
||
elif args.command == "palette-cascade":
|
||
cascade = generate_cascade_palette(args.intent, args.mode, harmony=args.harmony, seed=args.seed)
|
||
if args.format == "json":
|
||
print(json.dumps(cascade, indent=2, ensure_ascii=False, default=str))
|
||
elif args.format == "css":
|
||
print(cascade["css"])
|
||
elif args.format == "reportlab":
|
||
print(cascade["reportlab"])
|
||
else: # summary
|
||
meta = cascade["meta"]
|
||
print(f"🎨 Cascade Palette | Intent: {meta['intent']} | Mode: {meta['mode']} | Harmony: {meta['harmony']}")
|
||
print(f" Base hue: {meta['base_hue']}° | Accent hue: {meta['accent_hue']}° | Secondary hue: {meta['secondary_hue']}°")
|
||
print(f" Contrast: text:bg={meta['contrast']['text_on_bg']} | accent:bg={meta['contrast']['accent_on_bg']}")
|
||
print()
|
||
print(" TIER | ROLE | HEX | S | USAGE")
|
||
print(" ────── | ────────────────── | ─────── | ────── | ────────────")
|
||
for name, info in cascade["roles"].items():
|
||
tier = info['tier'].upper().ljust(6)
|
||
nm = name.ljust(18)
|
||
hx = info['hex'].ljust(7)
|
||
s_val = f"{info['hsl'][1]:.3f}".ljust(6)
|
||
print(f" {tier} | {nm} | {hx} | {s_val} | {info['usage']}")
|
||
print()
|
||
print(" Semantic:")
|
||
for name, info in cascade["semantic"].items():
|
||
print(f" {name}: {info['hex']} (S={info['hsl'][1]:.3f})")
|
||
if meta["audit"]:
|
||
print(f"\n ⚠️ Violations:")
|
||
for v in meta["audit"]:
|
||
print(f" - {v}")
|
||
else:
|
||
print(f"\n ✅ All tier constraints pass")
|
||
|
||
elif args.command == "compile":
|
||
try:
|
||
out_path, pal = compile_blueprint(args.blueprint, args.output)
|
||
print(f"✅ Blueprint compiled successfully to: {out_path}")
|
||
violations = audit_palette(pal)
|
||
if violations:
|
||
print(f"⚠️ Warning: Generated palette had minor violations (auto-corrected by engine):")
|
||
for v in violations: print(f" - {v}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to compile blueprint: {str(e)}")
|
||
sys.exit(1)
|
||
else:
|
||
parser.print_help()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# 4. BLUEPRINT COMPILER — Converting JSON Intent to HTML Canvas
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
import re
|
||
|
||
# Base CSS that enforces the "Axioms" from visual_framework.md
|
||
BASE_CSS = """
|
||
@page {
|
||
size: var(--canvas-w, 720px) var(--canvas-h, 960px);
|
||
margin: 0;
|
||
}
|
||
:root {
|
||
--font-sans: 'Inter', 'Noto Sans SC', 'Helvetica Neue', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
|
||
--font-serif: 'Playfair Display', 'Noto Serif SC', 'Cormorant Garamond', 'Apple Color Emoji', serif;
|
||
--font-mono: 'SF Mono', 'Consolas', 'Apple Color Emoji', monospace;
|
||
|
||
/* Typographic Scale — 6-level fluid type system (Modular Scale) */
|
||
--text-scale-6: clamp(64px, 12vw, 150px); /* Hero / Display — oversized, single word or short phrase */
|
||
--text-scale-5: clamp(48px, 8vw, 96px); /* Primary Title — poster headline */
|
||
--text-scale-4: clamp(32px, 5vw, 56px); /* Subheadline — chapter opener or key quote */
|
||
--text-scale-3: clamp(20px, 3vw, 32px); /* Lead Paragraph — slightly larger than body */
|
||
--text-scale-2: 16px; /* Body — standard body text */
|
||
--text-scale-1: 12px; /* Meta / Caption — minimum readable size */
|
||
}
|
||
html, body {
|
||
margin: 0; padding: 0;
|
||
background: var(--c-bg);
|
||
color: var(--c-text);
|
||
font-family: var(--font-sans);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
/* Browser preview: scale poster to fit viewport & center on matching background */
|
||
@media screen {
|
||
html {
|
||
height: auto;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
min-height: 100vh;
|
||
background: var(--c-bg);
|
||
}
|
||
body {
|
||
transform-origin: top center;
|
||
margin: 0 auto;
|
||
box-shadow: 0 0 60px rgba(0,0,0,0.3);
|
||
/* scale injected by override_css with concrete canvas dimensions */
|
||
}
|
||
}
|
||
.canvas {
|
||
width: var(--canvas-w, 720px);
|
||
min-height: var(--canvas-h, 960px);
|
||
position: relative;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
page-break-after: always;
|
||
}
|
||
/* ═══ Continuous Canvas Mode (multi-page as one seamless surface) ═══ */
|
||
.continuous-canvas {
|
||
width: var(--canvas-w, 720px);
|
||
position: relative;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
/* height is set inline: canvas_h * total_pages */
|
||
}
|
||
.continuous-canvas .bg-layer-full {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
}
|
||
.continuous-canvas .bg-layer-full svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.continuous-canvas .page-section {
|
||
position: absolute;
|
||
left: 0;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
overflow: visible;
|
||
/* top and height set inline per page */
|
||
}
|
||
.continuous-canvas .page-section .safe-zone {
|
||
position: absolute;
|
||
inset: 10% 12%;
|
||
display: grid;
|
||
grid-template-columns: repeat(12, 1fr);
|
||
grid-template-rows: repeat(12, minmax(0, auto));
|
||
align-content: start;
|
||
}
|
||
.continuous-canvas .page-section .page-ghost {
|
||
position: absolute;
|
||
bottom: -5%;
|
||
right: 5%;
|
||
font-size: 240px;
|
||
font-weight: 900;
|
||
color: var(--c-mid);
|
||
opacity: 0.05;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
/* 12% Breathing Margin Enforcer (balanced: enough air without wasting space) */
|
||
.safe-zone {
|
||
position: absolute;
|
||
inset: 10% 12%;
|
||
display: grid;
|
||
grid-template-columns: repeat(12, 1fr);
|
||
grid-template-rows: repeat(12, minmax(0, auto));
|
||
align-content: start;
|
||
/* gap is injected dynamically by compile_blueprint via inline style */
|
||
}
|
||
/* Grid-item wrapper — every component gets one */
|
||
.grid-item {
|
||
display: flex;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
overflow: visible;
|
||
}
|
||
/* Global content-overflow protection — prevents ANY text/block from breaking out of its container */
|
||
.grid-item * {
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.grid-item p, .grid-item li, .grid-item span, .grid-item h1, .grid-item h2, .grid-item h3, .grid-item h4, .grid-item td, .grid-item th, .grid-item div {
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
}
|
||
/* CJK-safe text wrapping: prefer keeping CJK word groups intact, but allow break when necessary */
|
||
.hero-type, .hero-sub, .glass-canvas, .shaped-content, .stat-label, .delta-metric, .delta-label {
|
||
text-wrap: balance;
|
||
overflow-wrap: break-word;
|
||
word-break: normal;
|
||
line-break: strict;
|
||
}
|
||
|
||
/* ═══ Micro-Typography: Computational Typesetting ═══ */
|
||
p, .glass-canvas, .shaped-content, li {
|
||
/* Algorithmic justification — eliminates white rivers between words */
|
||
text-align: justify;
|
||
hyphens: auto;
|
||
word-spacing: -0.05em;
|
||
/* Hanging punctuation — visually aligned optical edges */
|
||
hanging-punctuation: first last;
|
||
/* Kill orphans & widows — force min 3 lines carried across page breaks */
|
||
orphans: 3;
|
||
widows: 3;
|
||
}
|
||
.grid-item table {
|
||
width: 100%;
|
||
table-layout: fixed;
|
||
overflow: hidden;
|
||
}
|
||
.grid-item img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
|
||
/* --- Archetype Layouts --- */
|
||
/* All archetypes share the 12×12 grid via .safe-zone.
|
||
Archetype classes may override alignment defaults on the canvas level. */
|
||
.archetype-cover_hero .safe-zone {
|
||
align-content: start;
|
||
inset: 12% 14%;
|
||
}
|
||
/* Cover hero typography scale: larger text for covers */
|
||
.archetype-cover_hero .hero-type {
|
||
font-size: clamp(42px, 8vw, 72px);
|
||
line-height: 1.15;
|
||
}
|
||
.archetype-cover_hero .hero-sub {
|
||
font-size: clamp(22px, 4vw, 32px);
|
||
line-height: 1.3;
|
||
opacity: 0.85;
|
||
}
|
||
.archetype-cover_hero .floating-meta {
|
||
font-size: clamp(16px, 2.5vw, 22px);
|
||
}
|
||
.archetype-split_vertical { display: grid; grid-template-columns: 1fr 1fr; min-height: 960px; }
|
||
.archetype-split_vertical .safe-zone { position: relative; inset: auto; padding: 10%; display: grid; grid-template-columns: repeat(12, 1fr); grid-template-rows: repeat(12, 1fr); align-content: center; }
|
||
.archetype-split_vertical.single-column { grid-template-columns: 1fr; }
|
||
.archetype-editorial_flow .safe-zone { align-content: center; }
|
||
.archetype-scattered_canvas .safe-zone { /* same 12×12 grid — scattered effect via grid_area placement */ }
|
||
.archetype-data_dashboard .safe-zone { /* same 12×12 grid — dashboard tiles via grid_area placement */ }
|
||
.archetype-shaped_editorial .safe-zone { align-content: center; }
|
||
|
||
/* Continuous canvas archetype overrides (page-section inherits archetype class) */
|
||
.continuous-canvas .page-section.archetype-cover_hero .safe-zone { align-content: start; inset: 12% 14%; }
|
||
.continuous-canvas .page-section.archetype-cover_hero .hero-type { font-size: clamp(42px, 8vw, 72px); line-height: 1.15; }
|
||
.continuous-canvas .page-section.archetype-cover_hero .hero-sub { font-size: clamp(22px, 4vw, 32px); line-height: 1.3; opacity: 0.85; }
|
||
.continuous-canvas .page-section.archetype-cover_hero .floating-meta { font-size: clamp(16px, 2.5vw, 22px); }
|
||
.continuous-canvas .page-section.archetype-editorial_flow .safe-zone { align-content: center; }
|
||
.continuous-canvas .page-section.archetype-shaped_editorial .safe-zone { align-content: center; inset: 5% 6%; }
|
||
|
||
/* --- Components --- */
|
||
.hero-type {
|
||
font-size: clamp(48px, 10vw, 110px);
|
||
line-height: 0.88;
|
||
letter-spacing: -0.03em;
|
||
margin: 0;
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
.hero-type.weight-black { font-weight: 900; }
|
||
.hero-type.weight-thin { font-weight: 100; letter-spacing: 0.05em; font-family: var(--font-serif); }
|
||
.hero-sub {
|
||
margin: 8px 0 0 0;
|
||
font-size: var(--text-scale-2);
|
||
line-height: 1.3;
|
||
opacity: 0.8;
|
||
}
|
||
/* Hero container needs vertical stacking */
|
||
.grid-item:has(.hero-type) {
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
}
|
||
.hero-type, .hero-sub {
|
||
width: 100%;
|
||
}
|
||
.hero-type {
|
||
color: var(--c-text);
|
||
}
|
||
|
||
.glass-canvas {
|
||
background: var(--c-surface);
|
||
border: 1px solid rgba(128, 128, 128, 0.08);
|
||
border-radius: 2px;
|
||
padding: 36px;
|
||
font-size: 16px;
|
||
line-height: 1.6;
|
||
z-index: 5;
|
||
position: relative;
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.floating-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
font-size: 12px;
|
||
font-family: var(--font-mono);
|
||
letter-spacing: 0.1em;
|
||
color: var(--c-muted);
|
||
opacity: 0.6;
|
||
z-index: 20;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
/* Positioned via grid_area in .grid-item wrapper — no absolute needed */
|
||
}
|
||
/* Legacy position helpers (kept for backwards compat with old blueprints) */
|
||
.pos-top-left { align-self: start; justify-self: start; }
|
||
.pos-top-right { align-self: start; justify-self: end; text-align: right; }
|
||
.pos-bottom-left { align-self: end; justify-self: start; }
|
||
.pos-bottom-right { align-self: end; justify-self: end; text-align: right; }
|
||
|
||
.stat-block { display: flex; flex-direction: column; margin-bottom: 24px; }
|
||
.stat-num { font-size: clamp(32px, 5vw, 56px); font-weight: 900; line-height: 0.9; color: var(--c-text); }
|
||
.stat-unit { font-size: clamp(14px, 2vw, 20px); font-weight: 300; color: var(--c-muted); margin-left: 4px; display: inline;}
|
||
.stat-label { font-size: 12px; letter-spacing: 0.15em; color: var(--c-accent); margin-top: 8px; text-transform: uppercase; }
|
||
|
||
.hairline { border: none; border-top: 0.5px solid var(--c-muted); opacity: 0.3; margin: 8px 0; width: 100%; }
|
||
.hairline.style-accent { border-top-color: var(--c-accent); width: 30%; margin-left: 0; opacity: 0.8;}
|
||
|
||
.page-ghost {
|
||
position: absolute; bottom: -5%; right: 5%;
|
||
font-size: 240px; font-weight: 900; color: var(--c-mid);
|
||
opacity: 0.05; pointer-events: none; z-index: 0;
|
||
}
|
||
|
||
.bg-layer { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
|
||
.bg-layer svg { width: 100%; height: 100%; }
|
||
|
||
/* --- Shaped_Canvas (Semantic Shape-Wrapping) --- */
|
||
.shaped-canvas {
|
||
position: relative;
|
||
padding: 24px;
|
||
font-size: 16px;
|
||
line-height: 1.7;
|
||
z-index: 5;
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
}
|
||
.shape-float {
|
||
float: left;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
.shape-circle { shape-outside: circle(45% at 50% 50%); width: 40%; height: 90%; }
|
||
.shape-wave { shape-outside: polygon(0 0, 80% 0, 60% 25%, 80% 50%, 60% 75%, 80% 100%, 0 100%); width: 45%; height: 100%; }
|
||
.shape-diagonal_slash { shape-outside: polygon(0 0, 100% 0, 0 100%); width: 50%; height: 100%; }
|
||
.shape-diamond { shape-outside: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); width: 45%; height: 90%; }
|
||
.shape-wedge_right { shape-outside: polygon(0 0, 60% 0, 100% 50%, 60% 100%, 0 100%); width: 50%; height: 100%; }
|
||
|
||
/* --- Archetype: shaped_editorial --- */
|
||
.archetype-shaped_editorial .safe-zone {
|
||
inset: 5% 6%;
|
||
/* Inherits 12×12 grid from .safe-zone */
|
||
align-content: center;
|
||
}
|
||
|
||
/* ═══ Tufte Marginalia System ═══ */
|
||
/* 30% sidenote rail for report/long-form archetypes */
|
||
.archetype-tufte_report .safe-zone {
|
||
display: grid;
|
||
grid-template-columns: 1fr 280px;
|
||
gap: 40px;
|
||
align-content: start;
|
||
}
|
||
.archetype-tufte_report .main-column {
|
||
display: grid;
|
||
grid-template-columns: repeat(12, 1fr);
|
||
grid-template-rows: repeat(12, 1fr);
|
||
gap: inherit;
|
||
}
|
||
.archetype-tufte_report .side-rail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
padding-top: 8px;
|
||
}
|
||
.sidenote {
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
color: var(--c-muted);
|
||
border-left: 2px solid var(--c-accent);
|
||
padding-left: 12px;
|
||
opacity: 0.85;
|
||
}
|
||
.sidenote .sidenote-label {
|
||
font-weight: 700;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--c-accent);
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
/* ═══ Delta Widget — Data-to-Ink Ratio Component ═══ */
|
||
.delta-widget {
|
||
text-align: center;
|
||
padding: 16px 12px;
|
||
}
|
||
.delta-widget .delta-metric {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
color: var(--c-muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
.delta-widget .delta-value {
|
||
font-size: 36px;
|
||
font-weight: 900;
|
||
color: var(--c-text);
|
||
line-height: 1.1;
|
||
}
|
||
.delta-widget .delta-change {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
margin-top: 4px;
|
||
}
|
||
.delta-widget .delta-label {
|
||
font-size: 12px;
|
||
color: var(--c-muted);
|
||
margin-top: 8px;
|
||
}
|
||
|
||
/* ═══ Polymorphic Process_List — Container Query Adaptive ═══ */
|
||
.process-list-container {
|
||
container-type: inline-size;
|
||
width: 100%;
|
||
min-height: 100%;
|
||
}
|
||
/* Wide: horizontal timeline */
|
||
.process-list {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
min-height: 100%;
|
||
}
|
||
.process-list .process-step {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
position: relative;
|
||
padding: 12px 4px;
|
||
}
|
||
.process-step .step-num {
|
||
width: 32px; height: 32px; border-radius: 50%;
|
||
background: var(--c-accent); color: #fff;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 800; font-size: 14px; margin-bottom: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
.process-step .step-title { font-weight: 700; font-size: 13px; color: var(--c-text); }
|
||
.process-step .step-desc { font-size: 12px; color: var(--c-muted); margin-top: 4px; line-height: 1.4; }
|
||
/* Connector line between horizontal steps */
|
||
.process-step:not(:last-child)::after {
|
||
content: ''; position: absolute; top: 28px; right: -6px;
|
||
width: 12px; height: 2px; background: var(--c-accent); opacity: 0.5;
|
||
}
|
||
/* Narrow: vertical numbered list */
|
||
@container (max-width: 360px) {
|
||
.process-list {
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.process-list .process-step {
|
||
flex-direction: row;
|
||
text-align: left;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
padding: 4px 0;
|
||
}
|
||
.process-step .step-num { margin-bottom: 0; }
|
||
.process-step:not(:last-child)::after { display: none; }
|
||
}
|
||
"""
|
||
|
||
|
||
def _prevent_orphan_chars(text):
|
||
"""
|
||
Prevent orphan characters at end of paragraphs.
|
||
Replace the last space/breakable point between the final two CJK chars
|
||
(or words) with so the browser never wraps a single trailing char
|
||
onto its own line.
|
||
"""
|
||
# CJK orphan: bind last two CJK characters with a zero-width no-break joiner
|
||
# Match: (CJK char)(optional space)(CJK char) at end of string (before tags)
|
||
text = re.sub(r'([\u4e00-\u9fff\u3400-\u4dbf])[\s]+([\u4e00-\u9fff\u3400-\u4dbf])(?=\s*(?:<[^>]*>)*\s*$)', '\\g<1>\u2060\\g<2>', text)
|
||
# Latin orphan: bind last two words
|
||
text = re.sub(r'(\S+)\s+(\S+)\s*$', r'\1 \2', text)
|
||
return text
|
||
|
||
|
||
def simple_markdown_to_html(md_text):
|
||
"""Lightweight markdown → HTML for Glass Canvas. Handles paragraphs, headers, bold, italic, lists, and inline code."""
|
||
if not md_text:
|
||
return ""
|
||
|
||
lines = md_text.split('\n')
|
||
html_parts = []
|
||
in_list = False
|
||
paragraph_buffer = []
|
||
|
||
def flush_paragraph():
|
||
nonlocal paragraph_buffer
|
||
if paragraph_buffer:
|
||
text = ' '.join(paragraph_buffer)
|
||
# Apply inline formatting
|
||
text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', text)
|
||
text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', text)
|
||
text = re.sub(r'`(.*?)`', r'<code style="background:var(--c-surface);padding:2px 6px;border-radius:3px;font-size:max(12px, 0.9em)">\1</code>', text)
|
||
# Anti-orphan: prevent single trailing character on last line
|
||
text = _prevent_orphan_chars(text)
|
||
html_parts.append(f'<p style="margin:0 0 12px 0;line-height:1.7">{text}</p>')
|
||
paragraph_buffer = []
|
||
|
||
def apply_inline(text):
|
||
"""Apply bold, italic, inline code."""
|
||
text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', text)
|
||
text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', text)
|
||
text = re.sub(r'`(.*?)`', r'<code style="background:var(--c-surface);padding:2px 6px;border-radius:3px;font-size:max(12px, 0.9em)">\1</code>', text)
|
||
return text
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
|
||
# Empty line — flush paragraph
|
||
if not stripped:
|
||
flush_paragraph()
|
||
if in_list:
|
||
html_parts.append('</ul>')
|
||
in_list = False
|
||
continue
|
||
|
||
# Headers
|
||
if stripped.startswith('### '):
|
||
flush_paragraph()
|
||
if in_list:
|
||
html_parts.append('</ul>')
|
||
in_list = False
|
||
html_parts.append(f'<h3 style="font-size:16px;font-weight:700;margin:20px 0 8px 0;color:var(--c-accent);text-transform:uppercase;letter-spacing:0.05em;">{apply_inline(stripped[4:])}</h3>')
|
||
continue
|
||
if stripped.startswith('## '):
|
||
flush_paragraph()
|
||
if in_list:
|
||
html_parts.append('</ul>')
|
||
in_list = False
|
||
html_parts.append(f'<h2 style="font-size:20px;font-weight:700;margin:24px 0 12px 0;color:var(--c-text)">{apply_inline(stripped[3:])}</h2>')
|
||
continue
|
||
if stripped.startswith('# '):
|
||
flush_paragraph()
|
||
if in_list:
|
||
html_parts.append('</ul>')
|
||
in_list = False
|
||
html_parts.append(f'<h1 style="font-size:24px;font-weight:800;margin:28px 0 14px 0;color:var(--c-text)">{apply_inline(stripped[2:])}</h1>')
|
||
continue
|
||
|
||
# List items (- or *)
|
||
list_match = re.match(r'^[-*]\s+(.*)', stripped)
|
||
if list_match:
|
||
flush_paragraph()
|
||
if not in_list:
|
||
html_parts.append('<ul style="margin:8px 0;padding-left:20px;list-style-type:disc">')
|
||
in_list = True
|
||
html_parts.append(f'<li style="margin:4px 0;line-height:1.6">{apply_inline(list_match.group(1))}</li>')
|
||
continue
|
||
|
||
# Numbered list items
|
||
num_match = re.match(r'^(\d+)[.)]\s+(.*)', stripped)
|
||
if num_match:
|
||
flush_paragraph()
|
||
if in_list:
|
||
html_parts.append('</ul>')
|
||
in_list = False
|
||
html_parts.append(f'<p style="margin:4px 0 4px 20px;line-height:1.6"><strong>{num_match.group(1)}.</strong> {apply_inline(num_match.group(2))}</p>')
|
||
continue
|
||
|
||
# Normal text — accumulate into paragraph
|
||
paragraph_buffer.append(stripped)
|
||
|
||
# Flush remaining
|
||
flush_paragraph()
|
||
if in_list:
|
||
html_parts.append('</ul>')
|
||
|
||
return '\n'.join(html_parts)
|
||
|
||
|
||
def _parse_grid_area(comp):
|
||
"""
|
||
Parse grid_area from component JSON.
|
||
Accepts two formats:
|
||
- Array: [row_start, col_start, row_end, col_end] — 1-based, max 13.
|
||
- String: "row_start / col_start / row_end / col_end" — 1-based, max 13.
|
||
Returns CSS grid-area string or None.
|
||
"""
|
||
ga = comp.get("grid_area", None)
|
||
if ga is None:
|
||
return None
|
||
# Array format: [1, 1, 3, 5]
|
||
if isinstance(ga, list) and len(ga) == 4:
|
||
rs, cs, re, ce = [max(1, min(13, int(v))) for v in ga]
|
||
# Fix zero-height/zero-width grid areas (row_end must be > row_start)
|
||
if re <= rs:
|
||
re = min(13, rs + 1)
|
||
if ce <= cs:
|
||
ce = min(13, cs + 1)
|
||
return f"{rs} / {cs} / {re} / {ce}"
|
||
# String format: "1 / 1 / 3 / 5"
|
||
if isinstance(ga, str) and "/" in ga:
|
||
parts = [p.strip() for p in ga.split("/")]
|
||
if len(parts) == 4:
|
||
try:
|
||
rs, cs, re, ce = [max(1, min(13, int(p))) for p in parts]
|
||
# Fix zero-height/zero-width grid areas
|
||
if re <= rs:
|
||
re = min(13, rs + 1)
|
||
if ce <= cs:
|
||
ce = min(13, cs + 1)
|
||
return f"{rs} / {cs} / {re} / {ce}"
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _parse_align(comp):
|
||
"""
|
||
Parse align from component JSON.
|
||
Format: "vertical / horizontal" where each is start|center|end.
|
||
Returns (align-items, justify-content) tuple.
|
||
"""
|
||
align = comp.get("align", "start / start")
|
||
if "/" in str(align):
|
||
parts = [p.strip() for p in str(align).split("/")]
|
||
v = parts[0] if parts[0] in ("start", "center", "end") else "start"
|
||
h = parts[1] if len(parts) > 1 and parts[1] in ("start", "center", "end") else "start"
|
||
else:
|
||
v, h = "start", "start"
|
||
return v, h
|
||
|
||
|
||
def _estimate_content_weight(comp):
|
||
"""
|
||
Estimate the visual weight (space needed) of a component based on its content.
|
||
Returns a numeric weight proportional to how many grid rows it should occupy.
|
||
|
||
Text-heavy components (Glass_Canvas, Shaped_Canvas) get weight proportional to
|
||
character count. Fixed-height components (Stat_Block, Hero_Typography, etc.) get
|
||
a small fixed weight since they don't grow with content.
|
||
"""
|
||
ctype = comp.get("type", "")
|
||
|
||
# Fixed-height components: their visual size is independent of text length
|
||
FIXED_WEIGHTS = {
|
||
"Hero_Typography": 2.5,
|
||
"Stat_Block": 2.0,
|
||
"Delta_Widget": 2.0,
|
||
"Hairline_Divider": 1.0,
|
||
"Floating_Meta": 1.0, # usually positioned in corners, but fallback
|
||
"Image_Asset": 3.0,
|
||
}
|
||
if ctype in FIXED_WEIGHTS:
|
||
return FIXED_WEIGHTS[ctype]
|
||
|
||
# Text-heavy components: weight scales with content length
|
||
text = comp.get("markdown_content", "") or comp.get("body", "") or ""
|
||
|
||
# Also count sub-items (Process_List steps)
|
||
for step in comp.get("steps", []):
|
||
text += step.get("title", "") + step.get("description", "")
|
||
|
||
char_count = len(text)
|
||
|
||
if ctype in ("Glass_Canvas", "Shaped_Canvas", "Process_List"):
|
||
# Rough estimate: ~40 CJK chars per line at 16px in a ~500px wide container,
|
||
# ~1.6 line-height, so each 40 chars ≈ 1 visual line ≈ ~25px.
|
||
# Grid row on a 960px canvas ≈ 80px each (960/12).
|
||
# So 40 chars ≈ 0.3 grid rows. But Glass_Canvas has padding (~72px total),
|
||
# headings, list spacing, etc. Add base weight for the container itself.
|
||
base = 2.0 # minimum for padding + heading
|
||
text_rows = char_count / 120.0 # ~120 chars per grid-row worth of space
|
||
return base + text_rows
|
||
|
||
# Unknown component type: estimate from text length with a reasonable default
|
||
if char_count > 0:
|
||
return 2.0 + char_count / 120.0
|
||
return 2.0
|
||
|
||
|
||
def _assign_floating_meta(comp):
|
||
"""Assign grid_area to a Floating_Meta component based on its position."""
|
||
pos = comp.get("position", "top-right")
|
||
if "top" in pos and "left" in pos:
|
||
comp["grid_area"] = "1 / 1 / 2 / 7"
|
||
elif "top" in pos:
|
||
comp["grid_area"] = "1 / 7 / 2 / 13"
|
||
elif "bottom" in pos and "left" in pos:
|
||
comp["grid_area"] = "12 / 1 / 13 / 7"
|
||
else:
|
||
comp["grid_area"] = "12 / 7 / 13 / 13"
|
||
|
||
|
||
def _distribute_rows_by_weight(content_comps, start_row=1, end_row=13, full_width=True):
|
||
"""
|
||
Distribute grid rows among content components proportionally to their content weight.
|
||
Each component gets at least 2 rows. Components fill start_row to end_row seamlessly.
|
||
"""
|
||
nn = len(content_comps)
|
||
if nn == 0:
|
||
return
|
||
|
||
total_rows = end_row - start_row # available rows
|
||
|
||
# Calculate weights
|
||
weights = [_estimate_content_weight(c) for c in content_comps]
|
||
total_weight = sum(weights)
|
||
|
||
if total_weight == 0:
|
||
total_weight = nn # fallback: equal distribution
|
||
weights = [1.0] * nn
|
||
|
||
# Allocate rows proportionally, with minimum 2 per component
|
||
MIN_ROWS = 2
|
||
raw_rows = [(w / total_weight) * total_rows for w in weights]
|
||
|
||
# Ensure minimum, then redistribute excess
|
||
allocated = []
|
||
for r in raw_rows:
|
||
allocated.append(max(MIN_ROWS, round(r)))
|
||
|
||
# Adjust total to exactly fill the available rows
|
||
# First pass: if total exceeds available, shrink the largest allocations
|
||
while sum(allocated) > total_rows and any(a > MIN_ROWS for a in allocated):
|
||
max_idx = max(range(nn), key=lambda i: allocated[i])
|
||
allocated[max_idx] -= 1
|
||
|
||
# Second pass: if total is less than available, grow the heaviest components
|
||
while sum(allocated) < total_rows:
|
||
max_weight_idx = max(range(nn), key=lambda i: weights[i])
|
||
allocated[max_weight_idx] += 1
|
||
|
||
# Assign grid_area
|
||
col_start = 1
|
||
col_end = 13 if full_width else 7 # can be overridden by caller
|
||
current_row = start_row
|
||
for j, comp in enumerate(content_comps):
|
||
rs = current_row
|
||
re = min(end_row, current_row + allocated[j])
|
||
comp["grid_area"] = f"{rs} / {col_start} / {re} / {col_end}"
|
||
current_row = re
|
||
|
||
|
||
def _auto_assign_grid_areas(archetype, components):
|
||
"""
|
||
Auto-assign grid_area to components that don't have one,
|
||
based on the archetype and number of components.
|
||
Mutates components in-place.
|
||
Skips Page_Ghost_Number (uses absolute positioning, not grid).
|
||
|
||
Uses content-aware row distribution: text-heavy components (Glass_Canvas)
|
||
get more rows than fixed-height components (Stat_Block, Hero_Typography).
|
||
"""
|
||
# Filter out ghost numbers — they use absolute positioning, not grid
|
||
gridded = [c for c in components if c.get("type") != "Page_Ghost_Number"]
|
||
|
||
# Only process components without grid_area
|
||
needs_area = [c for c in gridded if not c.get("grid_area")]
|
||
if not needs_area:
|
||
return # All components already have grid_area
|
||
|
||
n = len(gridded)
|
||
|
||
if archetype == "cover_hero":
|
||
# Cover: stack vertically, full width, spread across page
|
||
# Typical: 2-4 components (Hero + Floating_Meta + optional divider/ghost)
|
||
if n <= 2:
|
||
slots = ["1 / 1 / 7 / 13", "9 / 1 / 13 / 13"]
|
||
elif n == 3:
|
||
slots = ["1 / 1 / 5 / 13", "5 / 1 / 9 / 13", "10 / 1 / 13 / 13"]
|
||
else:
|
||
slots = ["1 / 1 / 4 / 13", "4 / 1 / 7 / 13", "8 / 1 / 10 / 13", "10 / 1 / 13 / 13"]
|
||
for i, comp in enumerate(gridded):
|
||
if not comp.get("grid_area") and i < len(slots):
|
||
comp["grid_area"] = slots[i]
|
||
|
||
elif archetype == "data_dashboard":
|
||
# Dashboard: tile components in a 2-column or 3-column grid
|
||
has_area = [c for c in gridded if c.get("grid_area")]
|
||
no_area = [c for c in gridded if not c.get("grid_area")]
|
||
nn = len(no_area)
|
||
if nn <= 2:
|
||
slots = ["1 / 1 / 7 / 7", "1 / 7 / 7 / 13"]
|
||
elif nn <= 4:
|
||
slots = ["1 / 1 / 7 / 7", "1 / 7 / 7 / 13",
|
||
"7 / 1 / 13 / 7", "7 / 7 / 13 / 13"]
|
||
elif nn <= 6:
|
||
slots = ["1 / 1 / 5 / 7", "1 / 7 / 5 / 13",
|
||
"5 / 1 / 9 / 7", "5 / 7 / 9 / 13",
|
||
"9 / 1 / 13 / 7", "9 / 7 / 13 / 13"]
|
||
elif nn <= 9:
|
||
slots = ["1 / 1 / 5 / 5", "1 / 5 / 5 / 9", "1 / 9 / 5 / 13",
|
||
"5 / 1 / 9 / 5", "5 / 5 / 9 / 9", "5 / 9 / 9 / 13",
|
||
"9 / 1 / 13 / 5", "9 / 5 / 13 / 9", "9 / 9 / 13 / 13"]
|
||
else:
|
||
# Too many: 3-col grid, auto-expand rows
|
||
slots = []
|
||
cols = 3
|
||
row_h = max(2, 12 // ((nn + cols - 1) // cols))
|
||
for idx in range(nn):
|
||
r = idx // cols
|
||
c = idx % cols
|
||
rs = 1 + r * row_h
|
||
re = min(13, rs + row_h)
|
||
cs = 1 + c * 4
|
||
ce = min(13, cs + 4)
|
||
slots.append(f"{rs} / {cs} / {re} / {ce}")
|
||
j = 0
|
||
for comp in no_area:
|
||
if j < len(slots):
|
||
comp["grid_area"] = slots[j]
|
||
j += 1
|
||
|
||
elif archetype == "editorial_flow":
|
||
# Editorial: stack vertically, full width
|
||
# Handle Floating_Meta separately — assign to corners based on position
|
||
# Content components get rows proportional to their content weight
|
||
no_area = [c for c in gridded if not c.get("grid_area")]
|
||
content_comps = []
|
||
for comp in no_area:
|
||
if comp.get("type") == "Floating_Meta":
|
||
_assign_floating_meta(comp)
|
||
else:
|
||
content_comps.append(comp)
|
||
if content_comps:
|
||
_distribute_rows_by_weight(content_comps, start_row=1, end_row=13, full_width=True)
|
||
|
||
elif archetype == "split_vertical":
|
||
# Split: first half on left, second half on right
|
||
# Each side gets content-proportional row distribution
|
||
no_area = [c for c in gridded if not c.get("grid_area")]
|
||
nn = len(no_area)
|
||
mid = (nn + 1) // 2
|
||
left = no_area[:mid]
|
||
right = no_area[mid:]
|
||
# Left column: cols 1-7
|
||
if left:
|
||
weights_l = [_estimate_content_weight(c) for c in left]
|
||
total_w = sum(weights_l) or 1
|
||
current = 1
|
||
for j, comp in enumerate(left):
|
||
rows = max(2, round((weights_l[j] / total_w) * 12))
|
||
rs = current
|
||
re = min(13, current + rows)
|
||
comp["grid_area"] = f"{rs} / 1 / {re} / 7"
|
||
current = re
|
||
# Right column: cols 7-13
|
||
if right:
|
||
weights_r = [_estimate_content_weight(c) for c in right]
|
||
total_w = sum(weights_r) or 1
|
||
current = 1
|
||
for j, comp in enumerate(right):
|
||
rows = max(2, round((weights_r[j] / total_w) * 12))
|
||
rs = current
|
||
re = min(13, current + rows)
|
||
comp["grid_area"] = f"{rs} / 7 / {re} / 13"
|
||
current = re
|
||
|
||
elif archetype == "scattered_canvas":
|
||
# Scatter: distribute pseudo-randomly across the grid
|
||
no_area = [c for c in gridded if not c.get("grid_area")]
|
||
scatter_slots = [
|
||
"1 / 1 / 5 / 6", "1 / 7 / 4 / 13", "5 / 3 / 9 / 10",
|
||
"6 / 1 / 10 / 5", "7 / 8 / 11 / 13", "10 / 2 / 13 / 8",
|
||
"10 / 8 / 13 / 13", "3 / 1 / 6 / 5", "1 / 4 / 4 / 10",
|
||
]
|
||
for j, comp in enumerate(no_area):
|
||
if j < len(scatter_slots):
|
||
comp["grid_area"] = scatter_slots[j]
|
||
|
||
elif archetype == "shaped_editorial":
|
||
# Shaped: main shaped canvas gets most of the page
|
||
no_area = [c for c in gridded if not c.get("grid_area")]
|
||
for j, comp in enumerate(no_area):
|
||
if comp.get("type") == "Shaped_Canvas":
|
||
comp["grid_area"] = "2 / 2 / 12 / 12"
|
||
elif comp.get("type") == "Floating_Meta":
|
||
_assign_floating_meta(comp)
|
||
else:
|
||
comp["grid_area"] = f"{1 + j * 3} / 1 / {min(13, 4 + j * 3)} / 13"
|
||
|
||
# Fallback: if still no grid_area, make full-width stacked rows
|
||
# Uses content-aware distribution instead of equal division
|
||
remaining = [c for c in gridded if not c.get("grid_area")]
|
||
if remaining:
|
||
# First, handle Floating_Meta components by position
|
||
non_meta = []
|
||
for comp in remaining:
|
||
if comp.get("type") == "Floating_Meta":
|
||
_assign_floating_meta(comp)
|
||
else:
|
||
non_meta.append(comp)
|
||
# Then distribute rows proportionally to content weight
|
||
if non_meta:
|
||
_distribute_rows_by_weight(non_meta, start_row=1, end_row=13, full_width=True)
|
||
|
||
|
||
def _wrap_grid_item(comp, inner_html):
|
||
"""Wrap a rendered component in a .grid-item div with grid positioning."""
|
||
grid_area_css = _parse_grid_area(comp)
|
||
v_align, h_align = _parse_align(comp)
|
||
|
||
# Glass_Canvas and Process_List should stretch to fill their grid area
|
||
ctype = comp.get("type", "")
|
||
if ctype in ("Glass_Canvas", "Process_List"):
|
||
v_align = "stretch"
|
||
|
||
style_parts = []
|
||
if grid_area_css:
|
||
style_parts.append(f"grid-area: {grid_area_css}")
|
||
style_parts.append(f"align-items: {v_align}")
|
||
style_parts.append(f"justify-content: {h_align}")
|
||
|
||
style = "; ".join(style_parts) + ";"
|
||
return f'<div class="grid-item" style="{style}">\n{inner_html}</div>\n'
|
||
|
||
|
||
def render_component(comp):
|
||
"""Convert a JSON component object into HTML string, wrapped in grid-item."""
|
||
# Flatten nested "content" and "style" dicts into top-level for compat
|
||
# e.g. {"content": {"heading": "Hi"}} → {"heading": "Hi"}
|
||
_content = comp.get("content", None)
|
||
if isinstance(_content, dict):
|
||
for k, v in _content.items():
|
||
if k not in comp:
|
||
comp[k] = v
|
||
_style = comp.get("style", None)
|
||
if isinstance(_style, dict):
|
||
for k, v in _style.items():
|
||
if k not in comp:
|
||
comp[k] = v
|
||
|
||
ctype = comp.get("type", "")
|
||
inner = ""
|
||
|
||
if ctype == "Hero_Typography":
|
||
weight = comp.get("weight", "black")
|
||
heading = comp.get("heading", "")
|
||
subheading = comp.get("subheading", "")
|
||
# Fallback: if "content" is a plain string, use it as heading
|
||
raw_content = comp.get("content", "")
|
||
if not heading and isinstance(raw_content, str) and raw_content:
|
||
heading = raw_content
|
||
# Sanitize consecutive <br> — collapse 3+ into max 2
|
||
heading = re.sub(r'(<br\s*/?>){3,}', '<br><br>', heading)
|
||
if subheading:
|
||
subheading = re.sub(r'(<br\s*/?>){3,}', '<br><br>', subheading)
|
||
scale = comp.get("scale", None)
|
||
# Build inline style from both scale and custom style props
|
||
style_parts = []
|
||
if scale is not None and 1 <= int(scale) <= 6:
|
||
style_parts.append(f"font-size: var(--text-scale-{int(scale)})")
|
||
heading_font_size = comp.get("heading_font_size", "")
|
||
heading_color = comp.get("heading_color", "")
|
||
heading_ls = comp.get("heading_letter_spacing", "")
|
||
text_align = comp.get("text_align", "")
|
||
if heading_font_size:
|
||
style_parts.append(f"font-size: {heading_font_size}")
|
||
if heading_color:
|
||
style_parts.append(f"color: {heading_color}")
|
||
if heading_ls:
|
||
style_parts.append(f"letter-spacing: {heading_ls}")
|
||
if text_align:
|
||
style_parts.append(f"text-align: {text_align}")
|
||
h_style = f' style="{"; ".join(style_parts)}"' if style_parts else ""
|
||
inner = f'<h1 class="hero-type weight-{weight}"{h_style}>{heading}</h1>\n'
|
||
if subheading:
|
||
sub_style_parts = []
|
||
sub_fs = comp.get("subheading_font_size", "")
|
||
sub_color = comp.get("subheading_color", "")
|
||
sub_ls = comp.get("subheading_letter_spacing", "")
|
||
if sub_fs:
|
||
sub_style_parts.append(f"font-size: {sub_fs}")
|
||
if sub_color:
|
||
sub_style_parts.append(f"color: {sub_color}")
|
||
if sub_ls:
|
||
sub_style_parts.append(f"letter-spacing: {sub_ls}")
|
||
if text_align:
|
||
sub_style_parts.append(f"text-align: {text_align}")
|
||
s_style = f' style="{"; ".join(sub_style_parts)}"' if sub_style_parts else ""
|
||
inner += f'<p class="hero-sub"{s_style}>{subheading}</p>\n'
|
||
|
||
elif ctype == "Glass_Canvas":
|
||
md = comp.get("markdown_content", "") or comp.get("body", "")
|
||
html_content = simple_markdown_to_html(md)
|
||
# Build inline style from custom style props
|
||
gs_parts = ["width:100%", "min-height:100%", "box-sizing:border-box"]
|
||
|
||
# --- Auto font-size scaling for Glass_Canvas ---
|
||
# When content is too long for the allocated grid rows, shrink font-size
|
||
# to avoid overflow into adjacent components.
|
||
# Estimation: ~80 chars per row at 16px base font-size (with padding).
|
||
user_font_size = comp.get("font_size", "")
|
||
if not user_font_size: # Only auto-scale when user hasn't set a custom size
|
||
grid_area_str = comp.get("grid_area", "")
|
||
if grid_area_str:
|
||
try:
|
||
ga_parts = [int(x.strip()) for x in grid_area_str.split("/")]
|
||
allocated_rows = ga_parts[2] - ga_parts[0] # row_end - row_start
|
||
content_len = len(md)
|
||
chars_per_row = 80 # approximate chars that fit in one grid row at 16px
|
||
needed_rows = max(1, content_len / chars_per_row)
|
||
if needed_rows > allocated_rows:
|
||
# Scale down proportionally, but never below 12px
|
||
scale = allocated_rows / needed_rows
|
||
new_size = max(12, int(16 * scale))
|
||
if new_size < 16:
|
||
gs_parts.append(f"font-size: {new_size}px")
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
for prop in ["background", "border", "border_radius", "padding", "font_size", "color", "line_height", "text_align"]:
|
||
val = comp.get(prop, "")
|
||
if val:
|
||
css_prop = prop.replace("_", "-")
|
||
gs_parts.append(f"{css_prop}: {val}")
|
||
grid_style = "; ".join(gs_parts) + ";"
|
||
tension = comp.get("tension_score", None)
|
||
if tension is not None:
|
||
weight = int(300 + (float(tension) * 600))
|
||
inner = f'<div class="glass-canvas" style="{grid_style}font-variation-settings: \'wght\' {weight};">{html_content}</div>\n'
|
||
else:
|
||
inner = f'<div class="glass-canvas" style="{grid_style}">{html_content}</div>\n'
|
||
|
||
elif ctype == "Floating_Meta":
|
||
pos = comp.get("position", "top-left")
|
||
items_html = "".join([f"<span>{item}</span>" for item in comp.get("items", [])])
|
||
fm_style_parts = []
|
||
for prop in ["font_size", "color", "letter_spacing", "text_align"]:
|
||
val = comp.get(prop, "")
|
||
if val:
|
||
fm_style_parts.append(f"{prop.replace('_', '-')}: {val}")
|
||
fm_style = f' style="{"; ".join(fm_style_parts)}"' if fm_style_parts else ""
|
||
inner = f'<div class="floating-meta pos-{pos}"{fm_style}>{items_html}</div>\n'
|
||
|
||
elif ctype == "Stat_Block":
|
||
inner = f'''<div class="stat-block">
|
||
<div><span class="stat-num">{comp.get("number", "")}</span><span class="stat-unit">{comp.get("unit", "")}</span></div>
|
||
<span class="stat-label">{comp.get("label", "")}</span>
|
||
</div>\n'''
|
||
|
||
elif ctype == "Hairline_Divider":
|
||
style = comp.get("style", "bleed")
|
||
inner = f'<hr class="hairline style-{style}">\n'
|
||
|
||
elif ctype == "Page_Ghost_Number":
|
||
# Ghost numbers are decorative overlays — still use absolute positioning
|
||
return f'<div class="page-ghost">{comp.get("number", "")}</div>\n'
|
||
|
||
elif ctype == "Shaped_Canvas":
|
||
shape = comp.get("shape_keyword", "circle")
|
||
md = comp.get("markdown_content", "") or comp.get("body", "")
|
||
html_content = simple_markdown_to_html(md)
|
||
sc_style_parts = []
|
||
for prop in ["background", "border", "border_radius", "padding"]:
|
||
val = comp.get(prop, "")
|
||
if val:
|
||
sc_style_parts.append(f"{prop.replace('_', '-')}: {val}")
|
||
sc_style = f' style="{"; ".join(sc_style_parts)}"' if sc_style_parts else ""
|
||
inner = f'''<div class="shaped-canvas"{sc_style}>
|
||
<div class="shape-float shape-{shape}" aria-hidden="true"></div>
|
||
<div class="shaped-content">{html_content}</div>
|
||
</div>\n'''
|
||
|
||
elif ctype == "Image_Asset":
|
||
src = comp.get("src", "")
|
||
alt = comp.get("alt", "")
|
||
fit = comp.get("object_fit", "cover")
|
||
inner = f'<img src="{src}" alt="{alt}" style="width:100%;height:100%;object-fit:{fit};border-radius:2px;" />\n'
|
||
|
||
elif ctype == "Sidenote_Block":
|
||
label = comp.get("label", "")
|
||
body = comp.get("body", "") or comp.get("markdown_content", "")
|
||
html_body = simple_markdown_to_html(body)
|
||
label_html = f'<span class="sidenote-label">{label}</span>' if label else ""
|
||
inner = f'<div class="sidenote">{label_html}{html_body}</div>\n'
|
||
# Sidenotes bypass grid-item wrapping for Tufte layout — returned raw
|
||
return inner
|
||
|
||
elif ctype == "Delta_Widget":
|
||
metric = comp.get("metric", "")
|
||
value = comp.get("value", "")
|
||
delta = comp.get("delta", "")
|
||
trend = comp.get("trend", "up") # up / down / flat
|
||
label = comp.get("label", "")
|
||
trend_symbol = {"up": "▲", "down": "▼", "flat": "─"}.get(trend, "")
|
||
trend_color = {"up": "#22c55e", "down": "#ef4444", "flat": "var(--c-muted)"}.get(trend, "var(--c-muted)")
|
||
inner = f'''<div class="delta-widget">
|
||
<div class="delta-metric">{metric}</div>
|
||
<div class="delta-value">{value}</div>
|
||
<div class="delta-change" style="color:{trend_color}"><span>{trend_symbol}</span> {delta}</div>
|
||
<div class="delta-label">{label}</div>
|
||
</div>\n'''
|
||
|
||
elif ctype == "Process_List":
|
||
steps = comp.get("steps", [])
|
||
steps_html = ""
|
||
for i, step in enumerate(steps):
|
||
title = step.get("title", "")
|
||
desc = step.get("description", "")
|
||
steps_html += f'<li class="process-step"><span class="step-num">{i+1}</span><div><div class="step-title">{title}</div><div class="step-desc">{desc}</div></div></li>\n'
|
||
inner = f'<div class="process-list-container"><ul class="process-list">{steps_html}</ul></div>\n'
|
||
|
||
else:
|
||
return f"<!-- Unknown component: {ctype} -->\n"
|
||
|
||
return _wrap_grid_item(comp, inner)
|
||
|
||
|
||
def compile_blueprint(json_path, output_html_path):
|
||
"""Reads the LLM JSON blueprint and generates the final poster.html"""
|
||
with open(json_path, 'r', encoding='utf-8') as f:
|
||
blueprint = json.load(f)
|
||
|
||
art = blueprint.get("art_direction", {})
|
||
# Intent: auto-derive from document title if not explicitly provided
|
||
doc_title = blueprint.get("document_meta", {}).get("title", "")
|
||
intent = art.get("intent", None)
|
||
if not intent:
|
||
intent = derive_intent(doc_title) if doc_title else "neutral"
|
||
intent = intent.lower()
|
||
mode = art.get("palette_mode", "minimal").lower()
|
||
harmony = art.get("color_harmony", "auto").lower() # "auto" → intent-based recommendation
|
||
svg_type = art.get("background_svg", "flow")
|
||
pages = blueprint.get("pages", [])
|
||
total_pages = len(pages)
|
||
|
||
# 1. Compute Aesthetics — three pillars: intent + mode + harmony
|
||
palette = generate_color_palette(intent, mode, harmony=harmony)
|
||
css_vars = palette_to_css(palette)
|
||
|
||
# Detect if any component uses tension_score → switch to Variable Font URL
|
||
has_tension = False
|
||
for page in pages:
|
||
for comp in page.get("components", []):
|
||
if comp.get("tension_score") is not None:
|
||
has_tension = True
|
||
break
|
||
if has_tension:
|
||
break
|
||
|
||
# Generate SVG backgrounds
|
||
canvas_w = art.get("canvas_width", 720)
|
||
canvas_h = art.get("canvas_height", 960)
|
||
use_continuous = total_pages > 1 # Multi-page → continuous canvas mode
|
||
continuous_svgs = None
|
||
unified_svg = ""
|
||
bg_svg = ""
|
||
if use_continuous and svg_type != "none":
|
||
# Generate one unified SVG spanning the entire document
|
||
unified_svg = generate_unified_svg(canvas_w, canvas_h, total_pages, svg_type, palette['accent'])
|
||
elif not use_continuous:
|
||
if svg_type == "continuous_flow" and total_pages > 1:
|
||
continuous_svgs = generate_continuous_flow_svg(canvas_w, canvas_h, total_pages, palette['accent'])
|
||
elif svg_type != "none":
|
||
bg_svg = generate_generative_svg(svg_type, canvas_w, canvas_h, palette['accent'])
|
||
|
||
# Font URL: use variable axis range if tension is active
|
||
if has_tension:
|
||
font_url = "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Noto+Sans+SC:wght@300;400;500;700;900&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&display=swap"
|
||
else:
|
||
font_url = "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&family=Noto+Sans+SC:wght@300;400;500;700;900&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&display=swap"
|
||
|
||
# 1b. Dynamic Gap — intent-driven grid density
|
||
# If the LLM explicitly sets grid_gap in art_direction, use that (override).
|
||
# Otherwise, derive from intent.
|
||
explicit_gap = art.get("grid_gap", None)
|
||
if explicit_gap is not None:
|
||
dynamic_gap = str(explicit_gap) if "px" in str(explicit_gap) else f"{explicit_gap}px"
|
||
else:
|
||
gap_mapping = {
|
||
"serenity": "48px",
|
||
"elegance": "32px",
|
||
"minimalism": "40px",
|
||
"warmth": "24px",
|
||
"neutral": "16px",
|
||
"tension": "8px",
|
||
"energy": "4px",
|
||
}
|
||
dynamic_gap = gap_mapping.get(intent, "16px")
|
||
|
||
# 2. Build override CSS for custom canvas size, background, bleed
|
||
override_css = ""
|
||
override_css += f":root {{ --canvas-w: {canvas_w}px; --canvas-h: {canvas_h}px; }}\n"
|
||
# @page size MUST use concrete values (CSS variables are NOT resolved in @page rules)
|
||
override_css += f"@page {{ size: {canvas_w}px {canvas_h}px; margin: 0; }}\n"
|
||
# html/body must match canvas size for full-bleed PDF output
|
||
# Use min-height (not height) so content taller than canvas_h expands naturally
|
||
override_css += f"html, body {{ width: {canvas_w}px; min-height: {canvas_h}px; }}\n"
|
||
bg_color = art.get("background_color", "")
|
||
if bg_color:
|
||
override_css += f".canvas {{ background: {bg_color}; }}\n"
|
||
override_css += f".continuous-canvas {{ background: {bg_color}; }}\n"
|
||
if art.get("bleed", False):
|
||
override_css += ".safe-zone { inset: 0 !important; padding: 0 !important; }\n"
|
||
|
||
# Screen preview: inject concrete scale value (CSS calc with var may not work in scale)
|
||
override_css += f"@media screen {{ body {{ scale: min(1, calc(100vw / {canvas_w}), calc(100vh / {canvas_h})); }} }}\n"
|
||
|
||
# 3. Build HTML Document
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<link href="{font_url}" rel="stylesheet">
|
||
<title>{blueprint.get("document_meta", {}).get("title", "Document")}</title>
|
||
<style>
|
||
{css_vars}
|
||
{BASE_CSS}
|
||
{override_css}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
"""
|
||
|
||
# 3. Render Pages
|
||
if use_continuous:
|
||
# ═══ CONTINUOUS CANVAS MODE ═══
|
||
# Render all pages as one seamless surface, then let Playwright's page.pdf() slice it.
|
||
total_height = canvas_h * total_pages
|
||
html += f'\n<div class="continuous-canvas" style="height: {total_height}px; background: var(--c-bg);">\n'
|
||
|
||
# Unified background SVG spanning the entire document
|
||
if unified_svg:
|
||
html += f' <div class="bg-layer-full">{unified_svg}</div>\n'
|
||
|
||
# Render each page as an absolutely-positioned section within the continuous canvas
|
||
for page_idx, page in enumerate(pages):
|
||
archetype = page.get("archetype", "cover_hero")
|
||
page_top = page_idx * canvas_h
|
||
|
||
# Auto-assign grid areas
|
||
_auto_assign_grid_areas(archetype, page.get("components", []))
|
||
|
||
# Cognitive Load Margins
|
||
total_chars = 0
|
||
sidenotes = []
|
||
main_components = []
|
||
for comp in page.get("components", []):
|
||
text_content = comp.get("markdown_content", "") or comp.get("body", "") or ""
|
||
total_chars += len(text_content)
|
||
if comp.get("type") == "Sidenote_Block":
|
||
sidenotes.append(comp)
|
||
else:
|
||
main_components.append(comp)
|
||
for step in comp.get("steps", []):
|
||
total_chars += len(step.get("title", "")) + len(step.get("description", ""))
|
||
|
||
if total_chars > 500:
|
||
safe_inset = "8% 10%"
|
||
elif total_chars > 200:
|
||
safe_inset = "10% 12%"
|
||
else:
|
||
safe_inset = ""
|
||
safe_inset_style = f" inset: {safe_inset};" if safe_inset else ""
|
||
|
||
# Page section: positioned absolutely within continuous canvas
|
||
html += f'\n <div class="page-section archetype-{archetype}" style="top: {page_top}px; height: {canvas_h}px;">\n'
|
||
|
||
# Per-page data-driven SVG background (overlays on top of unified bg)
|
||
if svg_type != "none":
|
||
data_points = None
|
||
for comp in page.get("components", []):
|
||
dp = comp.get("data_points")
|
||
if dp and isinstance(dp, list) and all(isinstance(x, (int, float)) for x in dp):
|
||
data_points = dp
|
||
break
|
||
if data_points and len(data_points) >= 2:
|
||
page_svg = _generate_data_driven_svg(data_points, canvas_w, canvas_h, palette['accent'])
|
||
html += f' <div class="bg-layer" style="position:absolute;inset:0;z-index:2;pointer-events:none;">{page_svg}</div>\n'
|
||
|
||
# Tufte layout
|
||
if archetype == "tufte_report" and sidenotes:
|
||
html += f' <div class="safe-zone" style="gap: {dynamic_gap};{safe_inset_style}">\n'
|
||
html += f' <div class="main-column" style="gap: {dynamic_gap};">\n'
|
||
for comp in main_components:
|
||
html += " " + render_component(comp)
|
||
html += ' </div>\n'
|
||
html += ' <div class="side-rail">\n'
|
||
for comp in sidenotes:
|
||
html += " " + render_component(comp)
|
||
html += ' </div>\n'
|
||
html += ' </div>\n'
|
||
else:
|
||
html += f' <div class="safe-zone" style="gap: {dynamic_gap};{safe_inset_style}">\n'
|
||
for comp in page.get("components", []):
|
||
html += " " + render_component(comp)
|
||
html += ' </div>\n'
|
||
|
||
html += ' </div>\n'
|
||
|
||
html += '</div>\n'
|
||
|
||
else:
|
||
# ═══ LEGACY PER-PAGE MODE (single-page documents) ═══
|
||
for page_idx, page in enumerate(pages):
|
||
archetype = page.get("archetype", "cover_hero")
|
||
|
||
_auto_assign_grid_areas(archetype, page.get("components", []))
|
||
|
||
total_chars = 0
|
||
sidenotes = []
|
||
main_components = []
|
||
for comp in page.get("components", []):
|
||
text_content = comp.get("markdown_content", "") or comp.get("body", "") or ""
|
||
total_chars += len(text_content)
|
||
if comp.get("type") == "Sidenote_Block":
|
||
sidenotes.append(comp)
|
||
else:
|
||
main_components.append(comp)
|
||
for step in comp.get("steps", []):
|
||
total_chars += len(step.get("title", "")) + len(step.get("description", ""))
|
||
|
||
if total_chars > 500:
|
||
safe_inset = "8% 10%"
|
||
elif total_chars > 200:
|
||
safe_inset = "10% 12%"
|
||
else:
|
||
safe_inset = ""
|
||
safe_inset_style = f" inset: {safe_inset};" if safe_inset else ""
|
||
|
||
page_svg = ""
|
||
if svg_type != "none":
|
||
data_points = None
|
||
for comp in page.get("components", []):
|
||
dp = comp.get("data_points")
|
||
if dp and isinstance(dp, list) and all(isinstance(x, (int, float)) for x in dp):
|
||
data_points = dp
|
||
break
|
||
if data_points and len(data_points) >= 2:
|
||
page_svg = _generate_data_driven_svg(data_points, canvas_w, canvas_h, palette['accent'])
|
||
elif continuous_svgs and page_idx < len(continuous_svgs):
|
||
page_svg = continuous_svgs[page_idx]
|
||
elif bg_svg:
|
||
page_svg = bg_svg
|
||
|
||
html += f'\n<div class="canvas archetype-{archetype}">\n'
|
||
if page_svg:
|
||
html += f' <div class="bg-layer">{page_svg}</div>\n'
|
||
|
||
if archetype == "tufte_report" and sidenotes:
|
||
html += f' <div class="safe-zone" style="gap: {dynamic_gap};{safe_inset_style}">\n'
|
||
html += f' <div class="main-column" style="gap: {dynamic_gap};">\n'
|
||
for comp in main_components:
|
||
html += " " + render_component(comp)
|
||
html += ' </div>\n'
|
||
html += ' <div class="side-rail">\n'
|
||
for comp in sidenotes:
|
||
html += " " + render_component(comp)
|
||
html += ' </div>\n'
|
||
html += ' </div>\n</div>\n'
|
||
else:
|
||
html += f' <div class="safe-zone" style="gap: {dynamic_gap};{safe_inset_style}">\n'
|
||
for comp in page.get("components", []):
|
||
html += " " + render_component(comp)
|
||
html += ' </div>\n</div>\n'
|
||
|
||
html += "</body>\n</html>"
|
||
|
||
# 4. Save
|
||
with open(output_html_path, 'w', encoding='utf-8') as f:
|
||
f.write(html)
|
||
|
||
return output_html_path, palette
|
||
|
||
if __name__ == "__main__":
|
||
main()
|