#!/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'' 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 ' svg += f'\n ' svg += f'\n ' svg += f'\n ' svg += f'\n ' svg += '\n ' svg += '\n ' for i in range(curves): path = _random_bezier_path(w, h) sw = stroke_width + random.uniform(-20, 30) svg += f'\n ' svg += '\n' 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'{svg_paths}' 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'' svg += f'\n {gradient_def}' for i, (path, sw) in enumerate(paths_data): svg += f'\n ' svg += '\n' 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'' svg += f'\n {gradient_def}' for i, (path, sw) in enumerate(paths_data): svg += f'\n ' svg += '\n' return svg elif svg_type == "grid": # Grid pattern spanning full height svg = f'' spacing = 60 for x in range(0, w + 1, spacing): svg += f'\n ' for y in range(0, int(total_h) + 1, spacing): svg += f'\n ' svg += '\n' return svg elif svg_type == "noise": # Noise dots spanning full height svg = f'' 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 ' svg += '\n' 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('
') in_list = False html_parts.append(f'

{num_match.group(1)}. {apply_inline(num_match.group(2))}

') 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'{alt}\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'
{label_html}{html_body}
\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'''
{metric}
{value}
{trend_symbol} {delta}
{label}
\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()