#!/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''
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 '
# Vertical lines
for x in range(0, int(w) + 1, spacing):
svg += f'\n '
# Horizontal lines
for y in range(0, int(h) + 1, spacing):
svg += f'\n '
svg += '\n '
svg += '\n'
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"""
"""
svg += '\n'
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'\n'
# Filled area below the curve
fill_d = d + f"L {w},{h} L 0,{h} Z"
svg_paths += f'\n'
return f''
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 '
# 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 '
# 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 '
svg += '\n'
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 '
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 '
svg += '\n '
# 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 '
# Vertical lines
x = grid_x0
while x <= grid_x1:
svg += f'\n '
x += grid_spacing
# Horizontal lines
y = grid_y0
while y <= grid_y1:
svg += f'\n '
y += grid_spacing
svg += '\n '
# Region 3: Tick marks along the grid (ruler effect)
svg += f'\n '
x = grid_x0
while x <= grid_x1:
svg += f'\n '
x += grid_spacing
svg += '\n '
# Region 4: Flowing contour lines (Bézier) across mid-right area
svg += f'\n '
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 '
svg += '\n '
# 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 '
svg += f'\n '
svg += f'\n '
svg += f'\n '
svg += '\n '
svg += '\n'
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''''''
for page_idx in range(total_pages):
vy = page_idx * h
svg = f''
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''''''
svg = f''
return svg
elif svg_type == "grid":
# Grid pattern spanning full height
svg = f''
return svg
elif svg_type == "noise":
# Noise dots spanning full height
svg = f''
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'\1', text)
text = re.sub(r'\*(.*?)\*', r'\1', text)
text = re.sub(r'`(.*?)`', r'\1', text)
# Anti-orphan: prevent single trailing character on last line
text = _prevent_orphan_chars(text)
html_parts.append(f'
{text}
')
paragraph_buffer = []
def apply_inline(text):
"""Apply bold, italic, inline code."""
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
text = re.sub(r'\*(.*?)\*', r'\1', text)
text = re.sub(r'`(.*?)`', r'\1', 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('')
in_list = False
continue
# Headers
if stripped.startswith('### '):
flush_paragraph()
if in_list:
html_parts.append('')
in_list = False
html_parts.append(f'
{apply_inline(stripped[4:])}
')
continue
if stripped.startswith('## '):
flush_paragraph()
if in_list:
html_parts.append('')
in_list = False
html_parts.append(f'
{apply_inline(stripped[3:])}
')
continue
if stripped.startswith('# '):
flush_paragraph()
if in_list:
html_parts.append('')
in_list = False
html_parts.append(f'
{apply_inline(stripped[2:])}
')
continue
# List items (- or *)
list_match = re.match(r'^[-*]\s+(.*)', stripped)
if list_match:
flush_paragraph()
if not in_list:
html_parts.append('
')
in_list = True
html_parts.append(f'
{apply_inline(list_match.group(1))}
')
continue
# Numbered list items
num_match = re.match(r'^(\d+)[.)]\s+(.*)', stripped)
if num_match:
flush_paragraph()
if in_list:
html_parts.append('
')
continue
# Normal text — accumulate into paragraph
paragraph_buffer.append(stripped)
# Flush remaining
flush_paragraph()
if in_list:
html_parts.append('')
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'
\n{inner_html}
\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 — collapse 3+ into max 2
heading = re.sub(r'( ){3,}', '
', heading)
if subheading:
subheading = re.sub(r'( ){3,}', '
', 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'
{heading}
\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'
{subheading}
\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'
{html_content}
\n'
else:
inner = f'
{html_content}
\n'
elif ctype == "Floating_Meta":
pos = comp.get("position", "top-left")
items_html = "".join([f"{item}" 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'
{items_html}
\n'
elif ctype == "Stat_Block":
inner = f'''
{comp.get("number", "")}{comp.get("unit", "")}
{comp.get("label", "")}
\n'''
elif ctype == "Hairline_Divider":
style = comp.get("style", "bleed")
inner = f'\n'
elif ctype == "Page_Ghost_Number":
# Ghost numbers are decorative overlays — still use absolute positioning
return f'
{comp.get("number", "")}
\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'''
{html_content}
\n'''
elif ctype == "Image_Asset":
src = comp.get("src", "")
alt = comp.get("alt", "")
fit = comp.get("object_fit", "cover")
inner = f'\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'{label}' if label else ""
inner = f'
\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'
{i+1}
{title}
{desc}
\n'
inner = f'
{steps_html}
\n'
else:
return f"\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"""
{blueprint.get("document_meta", {}).get("title", "Document")}
"""
# 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
\n'
# Unified background SVG spanning the entire document
if unified_svg:
html += f'
{unified_svg}
\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
\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'
{page_svg}
\n'
# Tufte layout
if archetype == "tufte_report" and sidenotes:
html += f'
\n'
html += f'
\n'
for comp in main_components:
html += " " + render_component(comp)
html += '
\n'
html += '
\n'
for comp in sidenotes:
html += " " + render_component(comp)
html += '
\n'
html += '
\n'
else:
html += f'
\n'
for comp in page.get("components", []):
html += " " + render_component(comp)
html += '
\n'
html += '
\n'
html += '
\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
\n'
if page_svg:
html += f'
{page_svg}
\n'
if archetype == "tufte_report" and sidenotes:
html += f'
\n'
html += f'
\n'
for comp in main_components:
html += " " + render_component(comp)
html += '
\n'
html += '
\n'
for comp in sidenotes:
html += " " + render_component(comp)
html += '
\n'
html += '
\n
\n'
else:
html += f'
\n'
for comp in page.get("components", []):
html += " " + render_component(comp)
html += '
\n\n'
html += "\n"
# 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()