Files

1580 lines
47 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
cover.py Generate cover.html from tokens.json.
Usage:
python3 cover.py --tokens tokens.json --out cover.html
Reads tokens.json["cover_pattern"] and renders the matching HTML cover.
Cover fonts are loaded live via Google Fonts @import (no local caching).
Exit codes: 0 success, 1 bad args/missing file, 3 render error
"""
import argparse
import json
import sys
# ── Google Fonts loader ────────────────────────────────────────────────────────
def _gfonts_import(t: dict) -> str:
"""Return a CSS @import for the document's Google Fonts, if available."""
url = t.get("gfonts_import", "")
if url:
return f"@import url('{url}');"
return ""
# ── Shared CSS head (required by all patterns) ─────────────────────────────────
def _base_css(t: dict) -> str:
"""Critical reset + shared variables. Never remove these rules."""
return f"""
{_gfonts_import(t)}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
html, body {{
width: 794px; height: 1123px;
overflow: hidden;
background: {t['cover_bg']};
font-family: '{t['font_body']}', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}}
.page {{
position: relative;
width: 794px; height: 1123px;
background: {t['cover_bg']};
overflow: hidden;
}}
"""
# ── Dot-grid SVG helper ─────────────────────────────────────────────────────────
def _dot_grid(x0, y0, cols, rows, *, gap, r, color, opacity) -> str:
"""Render a dot-grid as an absolutely positioned SVG element."""
dots = []
for row in range(rows):
for col in range(cols):
cx = x0 + col * gap
cy = y0 + row * gap
dots.append(f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{color}"/>')
return (
f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
f'pointer-events:none;opacity:{opacity}" xmlns="http://www.w3.org/2000/svg">'
+ "".join(dots) + "</svg>"
)
# ── Cross-hatch SVG helper ──────────────────────────────────────────────────────
def _cross_hatch(color, opacity, spacing=32, stroke_w=0.5) -> str:
lines = []
for i in range(-20, 60):
x = i * spacing
lines.append(f'<line x1="{x}" y1="0" x2="{x + 1200}" y2="1200" stroke="{color}" stroke-width="{stroke_w}"/>')
return (
f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
f'pointer-events:none;opacity:{opacity};overflow:hidden" xmlns="http://www.w3.org/2000/svg">'
+ "".join(lines) + "</svg>"
)
# ── Pattern 1: Full-bleed block ────────────────────────────────────────────────
def _pattern_fullbleed(t: dict) -> str:
dot_grid = _dot_grid(
x0=500, y0=40, cols=10, rows=20, gap=24, r=1.8,
color=t["accent"], opacity=0.12
)
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f"""
<div style="font-size:14px;color:{t['muted']};letter-spacing:0.01em;
max-width:480px;line-height:1.5;margin-bottom:40px;">
{t['subtitle']}
</div>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
.label {{
font-size: 9px; font-weight: 500; letter-spacing: 0.22em;
color: {t['accent']}; text-transform: uppercase; margin-bottom: 28px;
}}
.title {{
font-family: '{t['font_display']}', 'Times New Roman', Georgia, serif;
font-weight: 900; font-size: 60px; line-height: 1.0;
color: {t['text_light']}; letter-spacing: -0.015em;
margin-bottom: 10px; max-width: 560px;
word-wrap: break-word;
}}
.rule {{
width: 52%; height: 1.5px;
background: linear-gradient(to right, {t['accent']}, transparent);
margin: 24px 0 20px;
}}
.content {{
position: absolute; left: 68px; right: 60px;
top: 0; bottom: 0;
display: flex; flex-direction: column; justify-content: center;
padding-top: 60px;
}}
.footer {{
position: absolute; bottom: 0; left: 0; right: 0;
height: 70px;
background: rgba(0,0,0,0.22);
display: flex; align-items: center;
justify-content: space-between;
padding: 0 68px;
}}
.footer-author {{ font-size: 11px; color: rgba(240,237,230,0.75); letter-spacing:0.04em; }}
.footer-date {{ font-size: 11px; color: {t['muted']}; letter-spacing: 0.04em; }}
</style>
</head>
<body>
<div class="page">
<!-- top-right accent strip -->
<div style="position:absolute;top:0;right:0;width:35%;height:4px;background:{t['accent']};"></div>
<!-- left vertical accent bar (gradient fade) -->
<div style="position:absolute;left:48px;top:18%;width:3px;height:60%;
background:linear-gradient(to bottom,{t['accent']},transparent);"></div>
<!-- dot grid background texture -->
{dot_grid}
<div class="content">
<div class="label">{t.get('doc_type','Document').upper()} &nbsp;·&nbsp; {t.get('date','')}</div>
<div class="title">{t['title']}</div>
<div class="rule"></div>
{subtitle_block}
</div>
<div class="footer">
<div class="footer-author">{t.get('author','')}</div>
<div class="footer-date">{t.get('date','')}</div>
</div>
</div>
</body></html>"""
# ── Pattern 2: Split panel ─────────────────────────────────────────────────────
def _pattern_split(t: dict) -> str:
dot_grid = _dot_grid(
x0=360, y0=120, cols=10, rows=18, gap=22, r=2,
color="#CCCCCC", opacity=0.25
)
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
.left-panel {{
position: absolute; top: 0; left: 0;
width: 330px; height: 1123px;
background: {t['cover_bg']};
display: flex; flex-direction: column;
justify-content: center;
padding: 0 44px;
}}
.right-panel {{
position: absolute; top: 0; left: 330px;
width: 464px; height: 1123px;
background: {t['page_bg']};
}}
.divider {{
position: absolute; top: 0; left: 329px;
width: 3px; height: 1123px;
background: {t['accent']};
}}
.left-top-bar {{
position: absolute; top: 0; left: 0;
width: 330px; height: 4px;
background: {t['accent']};
}}
.title {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 34px; line-height: 1.2;
color: {t['text_light']}; margin-bottom: 18px;
word-wrap: break-word;
}}
.rule {{
width: 55%; height: 1.5px;
background: {t['accent']};
margin-bottom: 14px;
}}
.subtitle {{
font-size: 12px; color: rgba(220,220,220,0.65);
line-height: 1.5; margin-bottom: 32px;
}}
.author {{
font-size: 11px; color: {t['text_light']}; margin-bottom: 4px;
}}
.date {{ font-size: 10px; color: {t['muted']}; }}
.right-label {{
position: absolute; bottom: 60px; right: 44px;
font-size: 9px; letter-spacing: 0.18em;
color: {t['muted']}; text-transform: uppercase;
}}
</style>
</head>
<body>
<div class="page">
<div class="left-top-bar"></div>
<div class="left-panel">
<div class="title">{t['title']}</div>
<div class="rule"></div>
{'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
<div class="author">{t.get('author','')}</div>
<div class="date">{t.get('date','')}</div>
</div>
<div class="right-panel">
{dot_grid}
</div>
<div class="divider"></div>
<div class="right-label">{t.get('doc_type','').upper()}</div>
</div>
</body></html>"""
# ── Pattern 3: Typographic ─────────────────────────────────────────────────────
def _pattern_typographic(t: dict) -> str:
words = t['title'].split()
first = words[0] if words else ""
rest = " ".join(words[1:]) if len(words) > 1 else ""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {t['page_bg']}; }}
.page {{ background: {t['page_bg']}; }}
.content {{
position: absolute; left: 60px; top: 0; bottom: 0; right: 60px;
display: flex; flex-direction: column; justify-content: center;
}}
.first-word {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 72px; line-height: 1.0;
color: {t['accent']}; letter-spacing: -0.02em;
}}
.rest-words {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 72px; line-height: 1.0;
color: {t['dark']}; letter-spacing: -0.02em;
margin-bottom: 12px;
}}
.rule {{
width: 100%; height: 1.5px;
background: linear-gradient(to right, {t['accent']}, {t['accent']}40);
margin: 28px 0 20px;
}}
.meta-row {{
display: flex; justify-content: space-between; align-items: baseline;
}}
.author {{ font-size: 13px; color: {t['dark']}; letter-spacing: 0.02em; }}
.date {{ font-size: 12px; color: {t['muted']}; }}
.subtitle {{ font-size: 13px; color: {t['muted']}; margin-top: 8px; max-width: 500px; }}
</style>
</head>
<body>
<div class="page">
<div class="content">
<div class="first-word">{first}</div>
{'<div class="rest-words">' + rest + '</div>' if rest else ''}
<div class="rule"></div>
<div class="meta-row">
<div class="author">{t.get('author','')}</div>
<div class="date">{t.get('date','')}</div>
</div>
{'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
</div>
</div>
</body></html>"""
# ── Pattern 4: Dark atmospheric ────────────────────────────────────────────────
def _pattern_atmospheric(t: dict) -> str:
dot_grid = _dot_grid(
x0=60, y0=60, cols=16, rows=22, gap=20, r=1.5,
color=t["accent"], opacity=0.08
)
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
.glow {{
position: absolute;
top: -100px; right: -80px;
width: 500px; height: 500px;
background: radial-gradient(circle, {t['accent']}2E 0%, transparent 68%);
border-radius: 50%;
}}
.glow2 {{
position: absolute;
bottom: -40px; left: 10%;
width: 300px; height: 300px;
background: radial-gradient(circle, {t['accent']}14 0%, transparent 70%);
border-radius: 50%;
}}
.content {{
position: absolute; left: 64px; right: 80px;
top: 0; bottom: 0;
display: flex; flex-direction: column; justify-content: center;
}}
.label {{
font-size: 9px; letter-spacing: 0.22em;
color: {t['accent']}; text-transform: uppercase; margin-bottom: 32px;
}}
.title {{
font-family: '{t['font_display']}', 'Times New Roman', serif;
font-weight: 900; font-size: 50px; line-height: 1.05;
color: {t['text_light']}; max-width: 520px;
word-wrap: break-word; margin-bottom: 12px;
}}
.rule {{ width: 48px; height: 2px; background: {t['accent']}; margin: 24px 0 20px; }}
.subtitle {{
font-size: 13px; color: {t['muted']}; line-height: 1.6;
max-width: 400px; margin-bottom: 40px;
}}
.footer {{
position: absolute; bottom: 0; left: 0; right: 0; height: 64px;
border-top: 1px solid rgba(255,255,255,0.06);
display: flex; align-items: center; justify-content: space-between;
padding: 0 64px;
}}
.footer-l {{ font-size: 10.5px; color: rgba(240,237,230,0.6); }}
.footer-r {{ font-size: 10.5px; color: {t['muted']}; }}
</style>
</head>
<body>
<div class="page">
<div class="glow"></div>
<div class="glow2"></div>
{dot_grid}
<div style="position:absolute;top:0;right:0;width:30%;height:3px;background:{t['accent']};"></div>
<div class="content">
<div class="label">{t.get('doc_type','').upper()} &nbsp;·&nbsp; {t.get('date','')}</div>
<div class="title">{t['title']}</div>
<div class="rule"></div>
{'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
</div>
<div class="footer">
<div class="footer-l">{t.get('author','')}</div>
<div class="footer-r">{t.get('date','')}</div>
</div>
</div>
</body></html>"""
# ── Pattern 5: Minimal — thick left bar, generous whitespace ───────────────────
def _pattern_minimal(t: dict) -> str:
"""
Ultra-restrained: white background, 8px left accent bar, oversized light-weight
title, nothing else but a hairline rule and minimal metadata. The bar is the only
color on the page everything else is black on white.
"""
# Pick text color for page (minimal uses page_bg which is near-white)
text_dark = t.get("dark", "#111111")
muted = t.get("muted", "#999999")
accent = t["accent"]
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {t['page_bg']}; }}
.page {{ background: {t['page_bg']}; }}
/* Left accent bar the only color element */
.bar {{
position: absolute;
top: 0; left: 0;
width: 8px; height: 1123px;
background: {accent};
}}
/* Main content column offset from bar */
.content {{
position: absolute;
left: 64px; right: 64px;
top: 0; bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 40px;
}}
.eyebrow {{
font-size: 9px;
font-weight: 500;
letter-spacing: 0.28em;
text-transform: uppercase;
color: {accent};
margin-bottom: 36px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 300;
font-size: 72px;
line-height: 1.0;
color: {text_dark};
letter-spacing: -0.02em;
max-width: 580px;
word-wrap: break-word;
margin-bottom: 0;
}}
.rule {{
width: 56px;
height: 1px;
background: {text_dark};
margin: 36px 0 24px;
opacity: 0.2;
}}
.subtitle {{
font-size: 13px;
font-weight: 300;
color: {muted};
line-height: 1.7;
max-width: 460px;
margin-bottom: 28px;
}}
.meta {{
font-size: 10px;
letter-spacing: 0.06em;
color: {muted};
margin-top: 4px;
}}
</style>
</head>
<body>
<div class="page">
<div class="bar"></div>
<div class="content">
<div class="eyebrow">{t.get('doc_type','').upper()}</div>
<div class="title">{t['title']}</div>
<div class="rule"></div>
{subtitle_block}
<div class="meta">{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}</div>
</div>
</div>
</body></html>"""
# ── Pattern 6: Stripe — bold horizontal bands ──────────────────────────────────
def _pattern_stripe(t: dict) -> str:
"""
Page divided into three bold horizontal bands:
- Top band (accent, ~18%): document type label
- Middle band (dark, ~52%): large title in white
- Bottom band (page bg, ~30%): author / date / subtitle
Hard geometry, no gradients, no textures. Newspaper / brand poster aesthetic.
"""
top_h = 200 # accent band
mid_h = 580 # dark band
bot_y = top_h + mid_h # 780
accent = t["accent"]
dark = t.get("cover_bg", "#1A1A2E")
light = t.get("page_bg", "#FAFAF8")
text_l = t.get("text_light", "#FFFFFF")
muted = t.get("muted", "#888888")
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {light}; }}
.page {{ background: {light}; }}
/* Three bands */
.band-top {{
position: absolute; top: 0; left: 0;
width: 794px; height: {top_h}px;
background: {accent};
display: flex; align-items: flex-end;
padding: 0 64px 24px;
}}
.band-mid {{
position: absolute; top: {top_h}px; left: 0;
width: 794px; height: {mid_h}px;
background: {dark};
display: flex; flex-direction: column; justify-content: center;
padding: 0 64px;
}}
.band-bot {{
position: absolute; top: {bot_y}px; left: 0;
width: 794px; height: {1123 - bot_y}px;
background: {light};
display: flex; flex-direction: column; justify-content: center;
padding: 0 64px;
}}
/* Top band doc type in large caps */
.eyebrow {{
font-family: '{t['font_display']}', sans-serif;
font-size: 11px; font-weight: 700;
letter-spacing: 0.32em; text-transform: uppercase;
color: {dark}; opacity: 0.85;
}}
/* Mid band title */
.title {{
font-family: '{t['font_display']}', 'Times New Roman', Georgia, serif;
font-weight: 900;
font-size: 62px;
line-height: 0.97;
color: {text_l};
letter-spacing: -0.02em;
max-width: 620px;
word-wrap: break-word;
}}
/* Thin horizontal separator between mid and bot */
.sep {{
position: absolute; top: {bot_y}px; left: 0;
width: 794px; height: 2px;
background: {accent};
}}
/* Bottom band */
.author {{
font-size: 13px; font-weight: 500;
color: {t.get('dark','#111')}; margin-bottom: 4px;
}}
.date {{ font-size: 11px; color: {muted}; margin-bottom: 12px; }}
.subtitle {{
font-size: 12px; color: {muted}; line-height: 1.6;
max-width: 540px;
}}
</style>
</head>
<body>
<div class="page">
<div class="band-top">
<div class="eyebrow">{t.get('doc_type','').upper()}</div>
</div>
<div class="band-mid">
<div class="title">{t['title']}</div>
</div>
<div class="sep"></div>
<div class="band-bot">
<div class="author">{t.get('author','')}</div>
<div class="date">{t.get('date','')}</div>
{subtitle_block}
</div>
</div>
</body></html>"""
# ── Pattern 7: Diagonal — angled color split ───────────────────────────────────
def _pattern_diagonal(t: dict) -> str:
"""
SVG polygon cuts the page diagonally: upper-left in dark cover color,
lower-right in light page bg. Title sits on the dark area, metadata on light.
One angled edge no gradients, no curves.
"""
dark_bg = t.get("cover_bg", "#1B2A4A")
light_bg = t.get("page_bg", "#FAFCFF")
accent = t["accent"]
text_l = t.get("text_light", "#F8FAFF")
text_d = t.get("dark", "#0F1A2E")
muted = t.get("muted", "#7A8A99")
# Polygon: full upper-left to ~60% down on right side
# Points: top-left, top-right, (794, 620), (0, 820)
poly = "0,0 794,0 794,620 0,820"
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle-lt">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {light_bg}; }}
.page {{ background: {light_bg}; overflow: hidden; }}
/* Title block upper dark area */
.content-dark {{
position: absolute;
left: 64px; right: 64px;
top: 180px;
z-index: 2;
}}
.eyebrow {{
font-size: 9px; font-weight: 500;
letter-spacing: 0.26em; text-transform: uppercase;
color: {accent}; margin-bottom: 28px;
}}
.title {{
font-family: '{t['font_display']}', 'Helvetica Neue', sans-serif;
font-weight: 900;
font-size: 58px;
line-height: 1.0;
color: {text_l};
letter-spacing: -0.018em;
max-width: 560px;
word-wrap: break-word;
margin-bottom: 16px;
}}
.rule-accent {{
width: 52px; height: 3px;
background: {accent};
margin-top: 28px;
}}
/* Metadata lower light area */
.content-light {{
position: absolute;
left: 64px; right: 64px;
bottom: 80px;
z-index: 2;
}}
.author {{
font-size: 12px; font-weight: 500;
color: {text_d}; margin-bottom: 4px;
}}
.date {{ font-size: 11px; color: {muted}; margin-bottom: 12px; }}
.subtitle-lt {{
font-size: 12px; color: {muted}; line-height: 1.6;
max-width: 480px;
}}
</style>
</head>
<body>
<div class="page">
<!-- Diagonal dark polygon -->
<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;z-index:1"
xmlns="http://www.w3.org/2000/svg">
<polygon points="{poly}" fill="{dark_bg}"/>
<!-- Accent edge line along the diagonal -->
<line x1="0" y1="820" x2="794" y2="620"
stroke="{accent}" stroke-width="2.5"/>
</svg>
<div class="content-dark">
<div class="eyebrow">{t.get('doc_type','').upper()}&nbsp; · &nbsp;{t.get('date','')}</div>
<div class="title">{t['title']}</div>
<div class="rule-accent"></div>
</div>
<div class="content-light">
<div class="author">{t.get('author','')}</div>
{subtitle_block}
</div>
</div>
</body></html>"""
# ── Pattern 8: Frame — elegant inset border ────────────────────────────────────
def _pattern_frame(t: dict) -> str:
"""
Classic formal layout: outer thin border line inset ~28px from page edges,
inner accent strip at top and bottom inside the frame.
Title centered in the frame space, classical serif typography.
Used for: academic papers, formal reports, legal docs, annual reports.
"""
bg = t.get("cover_bg", "#FAF8F3")
accent = t["accent"]
dark = t.get("dark", "#2A1A0A")
muted = t.get("muted", "#9A8A78")
pad = 28 # frame inset from page edge
inner_w = 794 - 2 * pad
inner_h = 1123 - 2 * pad
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Outer frame rectangle */
.frame {{
position: absolute;
top: {pad}px; left: {pad}px;
width: {inner_w}px; height: {inner_h}px;
border: 1.2px solid {dark};
opacity: 0.35;
}}
/* Accent strips inside top and bottom of frame */
.frame-top-accent {{
position: absolute;
top: {pad + 10}px; left: {pad + 10}px;
width: {inner_w - 20}px; height: 3px;
background: {accent};
}}
.frame-bot-accent {{
position: absolute;
bottom: {pad + 10}px; left: {pad + 10}px;
width: {inner_w - 20}px; height: 3px;
background: {accent};
}}
/* Corner ornament squares */
.corner {{
position: absolute;
width: 8px; height: 8px;
background: {accent};
opacity: 0.6;
}}
.tl {{ top: {pad - 4}px; left: {pad - 4}px; }}
.tr {{ top: {pad - 4}px; right: {pad - 4}px; }}
.bl {{ bottom: {pad - 4}px; left: {pad - 4}px; }}
.br {{ bottom: {pad - 4}px; right: {pad - 4}px; }}
/* Main content centered in frame */
.content {{
position: absolute;
left: {pad + 56}px; right: {pad + 56}px;
top: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}}
.eyebrow {{
font-size: 8.5px;
font-weight: 500;
letter-spacing: 0.30em;
text-transform: uppercase;
color: {accent};
margin-bottom: 44px;
}}
.rule-top {{
width: 60px; height: 1px;
background: {dark};
opacity: 0.3;
margin-bottom: 28px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 400;
font-size: 44px;
line-height: 1.25;
color: {dark};
letter-spacing: 0.01em;
max-width: 540px;
word-wrap: break-word;
margin-bottom: 0;
}}
.rule-mid {{
width: 40px; height: 1.5px;
background: {accent};
margin: 28px 0 20px;
}}
.subtitle {{
font-size: 13px;
font-weight: 300;
font-style: italic;
color: {muted};
line-height: 1.6;
max-width: 400px;
margin-bottom: 20px;
}}
.meta {{
font-size: 10px;
letter-spacing: 0.08em;
color: {muted};
margin-top: 8px;
}}
</style>
</head>
<body>
<div class="page">
<div class="frame"></div>
<div class="frame-top-accent"></div>
<div class="frame-bot-accent"></div>
<div class="corner tl"></div>
<div class="corner tr"></div>
<div class="corner bl"></div>
<div class="corner br"></div>
<div class="content">
<div class="eyebrow">{t.get('doc_type','').upper()}</div>
<div class="rule-top"></div>
<div class="title">{t['title']}</div>
<div class="rule-mid"></div>
{subtitle_block}
<div class="meta">{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}</div>
</div>
</div>
</body></html>"""
# ── Pattern 9: Editorial — oversized ghost letter + bold type ──────────────────
def _pattern_editorial(t: dict) -> str:
"""
Magazine / editorial feel:
- Oversized first-letter of title as a ghost background element (812% opacity)
- Bold category label at top in accent
- Title in very large condensed weight, flush-left
- Thin full-width rule separating title from metadata
- Author / date bottom-left, page type bottom-right
Designed for editorial reports, annual reviews, magazine-format content.
"""
bg = t.get("cover_bg", "#FFFFFF")
accent = t["accent"]
dark = t.get("dark", "#0A0A0A")
muted = t.get("muted", "#777777")
text_l = t.get("text_light", "#FFFFFF")
# Ghost letter — first character of title
ghost = t['title'][0].upper() if t['title'] else "A"
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
# Determine if background is dark (use light text) or light (use dark text)
is_dark_bg = (
bg.startswith("#0") or bg.startswith("#1") or bg.startswith("#2")
)
title_color = text_l if is_dark_bg else dark # noqa: F841
body_color = text_l if is_dark_bg else dark
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Ghost letter background texture */
.ghost {{
position: absolute;
right: -60px; top: -40px;
font-family: '{t['font_display']}', 'Arial Black', sans-serif;
font-weight: 900;
font-size: 680px;
line-height: 1;
color: {dark};
opacity: 0.055;
user-select: none;
letter-spacing: -0.05em;
}}
/* Top bar: accent stripe */
.topbar {{
position: absolute;
top: 0; left: 0; right: 0;
height: 5px;
background: {accent};
}}
/* Category label */
.category {{
position: absolute;
top: 40px; left: 60px;
font-size: 9px; font-weight: 700;
letter-spacing: 0.30em; text-transform: uppercase;
color: {accent};
}}
/* Main title block */
.content {{
position: absolute;
left: 60px; right: 60px;
top: 0; bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 80px;
}}
.title {{
font-family: '{t['font_display']}', 'Arial Black', Impact, sans-serif;
font-weight: 900;
font-size: 80px;
line-height: 0.92;
color: {body_color};
letter-spacing: -0.03em;
max-width: 620px;
word-wrap: break-word;
text-transform: uppercase;
}}
.subtitle {{
font-size: 14px;
font-weight: 400;
color: {muted};
line-height: 1.6;
max-width: 500px;
margin-top: 20px;
}}
/* Full-width rule above footer */
.footer-rule {{
position: absolute;
bottom: 80px; left: 60px; right: 60px;
height: 1px;
background: {body_color};
opacity: 0.15;
}}
/* Footer row */
.footer {{
position: absolute;
bottom: 44px; left: 60px; right: 60px;
display: flex;
justify-content: space-between;
align-items: baseline;
}}
.footer-author {{ font-size: 11px; color: {muted}; letter-spacing: 0.04em; }}
.footer-date {{ font-size: 10px; color: {muted}; letter-spacing: 0.04em; }}
</style>
</head>
<body>
<div class="page">
<div class="ghost">{ghost}</div>
<div class="topbar"></div>
<div class="category">{t.get('doc_type','').upper()}</div>
<div class="content">
<div class="title">{t['title']}</div>
{subtitle_block}
</div>
<div class="footer-rule"></div>
<div class="footer">
<div class="footer-author">{t.get('author','')}</div>
<div class="footer-date">{t.get('date','')}</div>
</div>
</div>
</body></html>"""
# ── Pattern 10: Magazine — elegant centered with optional hero image ────────────
def _pattern_magazine(t: dict) -> str:
"""
Upscale centered layout: company name + accent rule at top, large serif title,
decorative rule, italic subtitle, optional hero image, abstract block, author.
Used for: annual reports, strategic documents, formal publications.
"""
bg = t.get("cover_bg", "#F2F0EC")
accent = t["accent"]
dark = t.get("dark", "#0D1A2B")
muted = t.get("muted", "#888888")
org = t.get("doc_type", "").upper()
img_url = t.get("cover_image", "")
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
image_block = ""
if img_url:
image_block = f"""
<div style="text-align:center;margin:32px 0 28px;">
<img src="{img_url}" style="max-width:340px;max-height:220px;
object-fit:cover;display:inline-block;"/>
</div>"""
abstract_block = ""
if t.get("abstract"):
abstract_block = f"""
<div style="font-size:11px;line-height:1.7;color:{muted};
text-align:justify;max-width:560px;margin:0 auto 0;">
<span style="font-weight:700;color:{accent};">Abstract:</span>
{t['abstract']}
</div>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:60px 80px; }}
.org-name {{
font-size: 9px; font-weight: 500; letter-spacing: 0.30em;
text-transform: uppercase; color: {dark}; text-align:center;
margin-bottom: 10px;
}}
.org-rule {{
width: 56px; height: 2px; background: {accent};
margin: 0 auto 52px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 700; font-size: 52px; line-height: 1.08;
color: {dark}; text-align: center; letter-spacing: -0.015em;
max-width: 560px; word-wrap: break-word; margin-bottom: 18px;
}}
.title-rule {{
width: 44px; height: 2.5px; background: {accent};
margin: 0 auto 20px;
}}
.subtitle {{
font-family: '{t['font_display']}', Georgia, serif;
font-style: italic; font-size: 14px; color: {muted};
text-align: center; line-height: 1.5; max-width: 440px;
margin: 0 auto;
}}
.separator {{
width: 100%; max-width: 620px; height: 1px;
background: {dark}; opacity: 0.12;
margin: 28px auto;
}}
.author-name {{
font-family: '{t['font_display']}', Georgia, serif;
font-size: 16px; font-weight: 700; color: {accent};
text-align: center; margin-bottom: 6px;
}}
.date-line {{
font-size: 11px; color: {muted}; text-align: center;
letter-spacing: 0.03em;
}}
</style>
</head>
<body>
<div class="page">
<div class="org-name">{org}</div>
<div class="org-rule"></div>
<div class="title">{t['title']}</div>
<div class="title-rule"></div>
{subtitle_block}
{image_block}
{abstract_block}
{'<div class="separator"></div>' if (t.get('abstract') or img_url) else '<div style="margin:28px 0;"></div>'}
<div class="author-name">{t.get('author','')}</div>
<div class="date-line">{t.get('date','')}</div>
</div>
</body></html>"""
# ── Pattern 11: Darkroom — dark magazine variant ────────────────────────────────
def _pattern_darkroom(t: dict) -> str:
"""
Dark-background centered layout. Same structure as magazine but inverted:
deep navy page, white/silver text, accent rules in lighter tone.
Used for: premium reports, tech annual reviews, dark-themed documents.
"""
bg = t.get("cover_bg", "#151C27")
accent = t["accent"]
text_l = t.get("text_light", "#F0EDE6")
muted = t.get("muted", "#8A9AB0")
org = t.get("doc_type", "").upper()
img_url = t.get("cover_image", "")
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
image_block = ""
if img_url:
image_block = f"""
<div style="text-align:center;margin:32px 0 28px;">
<img src="{img_url}" style="max-width:340px;max-height:220px;
object-fit:cover;display:inline-block;
filter:grayscale(20%) brightness(0.9);"/>
</div>"""
abstract_block = ""
if t.get("abstract"):
abstract_block = f"""
<div style="font-size:11px;line-height:1.7;color:{muted};
text-align:justify;max-width:560px;margin:0 auto 0;">
<span style="font-weight:700;color:{accent};">Abstract:</span>
{t['abstract']}
</div>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:60px 80px; }}
.org-name {{
font-size: 9px; font-weight: 500; letter-spacing: 0.30em;
text-transform: uppercase; color: {text_l}; text-align:center;
opacity: 0.75; margin-bottom: 10px;
}}
.org-rule {{
width: 56px; height: 2px; background: {text_l};
opacity: 0.35; margin: 0 auto 52px;
}}
.title {{
font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
font-weight: 700; font-size: 52px; line-height: 1.08;
color: {text_l}; text-align: center; letter-spacing: -0.015em;
max-width: 560px; word-wrap: break-word; margin-bottom: 18px;
}}
.title-rule {{
width: 44px; height: 2.5px; background: {text_l};
opacity: 0.35; margin: 0 auto 20px;
}}
.subtitle {{
font-family: '{t['font_display']}', Georgia, serif;
font-style: italic; font-size: 14px; color: {muted};
text-align: center; line-height: 1.5; max-width: 440px;
margin: 0 auto;
}}
.separator {{
width: 100%; max-width: 620px; height: 1px;
background: {text_l}; opacity: 0.12;
margin: 28px auto;
}}
.author-name {{
font-family: '{t['font_display']}', Georgia, serif;
font-size: 16px; font-weight: 700; color: {text_l};
text-align: center; margin-bottom: 6px;
}}
.date-line {{
font-size: 11px; color: {muted}; text-align: center;
letter-spacing: 0.03em;
}}
</style>
</head>
<body>
<div class="page">
<div class="org-name">{org}</div>
<div class="org-rule"></div>
<div class="title">{t['title']}</div>
<div class="title-rule"></div>
{subtitle_block}
{image_block}
{abstract_block}
{'<div class="separator"></div>' if (t.get('abstract') or img_url) else '<div style="margin:28px 0;"></div>'}
<div class="author-name">{t.get('author','')}</div>
<div class="date-line">{t.get('date','')}</div>
</div>
</body></html>"""
# ── Pattern 12: Terminal — cyber/hacker aesthetic ───────────────────────────────
def _pattern_terminal(t: dict) -> str:
"""
Dark terminal/IDE aesthetic: grid overlay, monospace font, neon accent,
corner brackets around the title block, status bar at bottom.
Used for: tech reports, developer docs, security audits, system documentation.
"""
bg = t.get("cover_bg", "#0D1117")
accent = t["accent"]
text_l = t.get("text_light", "#E6EDF3")
muted = t.get("muted", "#48897C")
dark = t.get("dark", "#010409")
org = t.get("doc_type", "DOCUMENT").upper()
date_s = t.get("date", "")
author = t.get("author", "")
subtitle_line = ""
if t.get("subtitle"):
subtitle_line = f'<div class="subtitle">&gt; {t["subtitle"]}</div>'
abstract_block = ""
if t.get("abstract"):
abstract_block = f"""
<div class="abstract-text">{t['abstract']}</div>"""
# grid overlay: horizontal + vertical lines
h_lines = "".join(
f'<line x1="0" y1="{y}" x2="794" y2="{y}" stroke="{accent}" stroke-width="0.4"/>'
for y in range(0, 1124, 48)
)
v_lines = "".join(
f'<line x1="{x}" y1="0" x2="{x}" y2="1123" stroke="{accent}" stroke-width="0.4"/>'
for x in range(0, 795, 48)
)
grid_svg = (
f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
f'pointer-events:none;opacity:0.07" xmlns="http://www.w3.org/2000/svg">'
+ h_lines + v_lines + "</svg>"
)
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Terminal label top */
.term-label {{
position: absolute; top: 44px; left: 56px; right: 56px;
display: flex; align-items: center; gap: 10px;
}}
.dot {{
width: 8px; height: 8px; border-radius: 50%;
background: {accent}; flex-shrink: 0;
}}
.term-meta {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 10px; color: {accent}; letter-spacing: 0.08em;
text-transform: uppercase;
}}
/* Title bracket block */
.bracket-block {{
position: absolute;
top: 310px; left: 56px; right: 56px;
border-left: 2px solid {accent}; border-top: 2px solid {accent};
padding: 24px 28px 28px;
box-shadow: inset 0 0 0 0;
}}
.bracket-block::after {{
content: '';
position: absolute;
bottom: 0; right: 0;
width: 32px; height: 2px;
background: {accent};
}}
.bracket-block::before {{
content: '';
position: absolute;
bottom: 0; right: 0;
width: 2px; height: 32px;
background: {accent};
}}
.title {{
font-family: '{t['font_display']}', 'Courier New', monospace;
font-weight: 700; font-size: 46px; line-height: 1.05;
color: {text_l}; letter-spacing: 0.01em;
text-transform: uppercase;
word-wrap: break-word; margin-bottom: 16px;
}}
.subtitle {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 13px; color: {accent};
line-height: 1.5; letter-spacing: 0.02em;
margin-top: 8px;
}}
/* Content block below brackets */
.content-lower {{
position: absolute;
top: 640px; left: 56px; right: 56px;
display: flex; gap: 40px; align-items: flex-start;
}}
.abstract-text {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 10.5px; line-height: 1.8; color: {muted};
flex: 1;
}}
.author-block {{
text-align: right; flex-shrink: 0; min-width: 160px;
}}
.author-label {{
font-family: '{t['font_body']}', monospace;
font-size: 8px; letter-spacing: 0.20em; color: {muted};
text-transform: uppercase; margin-bottom: 6px;
}}
.author-name {{
font-family: '{t['font_body']}', monospace;
font-size: 14px; font-weight: 700; color: {text_l};
}}
.author-org {{
font-family: '{t['font_body']}', monospace;
font-size: 10px; color: {accent}; margin-top: 4px;
}}
/* Bottom status bar */
.statusbar {{
position: absolute; bottom: 0; left: 0; right: 0;
height: 36px; background: {accent}; opacity: 0.12;
}}
.statusbar-text {{
position: absolute; bottom: 0; left: 0; right: 0;
height: 36px; display: flex; align-items: center;
justify-content: space-between; padding: 0 56px;
}}
.sb-item {{
font-family: '{t['font_body']}', monospace;
font-size: 9px; color: {muted}; letter-spacing: 0.12em;
text-transform: uppercase;
}}
</style>
</head>
<body>
<div class="page">
{grid_svg}
<div class="term-label">
<div class="dot"></div>
<div class="term-meta">SYSTEM_REPORT // {date_s}</div>
</div>
<div class="bracket-block">
<div class="title">{t['title']}</div>
{subtitle_line}
</div>
<div class="content-lower">
{abstract_block}
<div class="author-block">
<div class="author-label">AUTHOR_ID</div>
<div class="author-name">{author}</div>
<div class="author-org">{org}</div>
</div>
</div>
<div class="statusbar"></div>
<div class="statusbar-text">
<div class="sb-item">Ln 1, Col 1</div>
<div class="sb-item">UTF-8</div>
<div class="sb-item">GENERATED_BY_COVERGENIUS</div>
</div>
</div>
</body></html>"""
# ── Pattern 13: Poster — bold sidebar + oversized type ─────────────────────────
def _pattern_poster(t: dict) -> str:
"""
Bold minimalist poster: thick vertical sidebar on the left, oversized all-caps
title, typewriter-style metadata. Optional thumbnail on the right side.
Used for: portfolios, creative reports, journalism, photography books.
"""
bg = t.get("cover_bg", "#FFFFFF")
accent = t["accent"] # typically black or strong dark
dark = t.get("dark", "#0A0A0A")
muted = t.get("muted", "#888888")
text_l = t.get("text_light", "#FFFFFF")
img_url = t.get("cover_image", "")
sidebar_w = 52
subtitle_block = ""
if t.get("subtitle"):
subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
image_block = ""
if img_url:
image_block = f"""
<img src="{img_url}" style="
width:260px;height:340px;object-fit:cover;
display:block;margin-top:32px;
filter:grayscale(100%) contrast(1.1);"/>"""
meta_lines = []
if t.get("author"):
meta_lines.append(f'<div class="meta-line">{t["author"]}</div>')
if t.get("subtitle"):
meta_lines.append(f'<div class="meta-line meta-role">{t["subtitle"]}</div>')
if t.get("date"):
meta_lines.append(f'<div class="meta-line meta-date">{t["date"]}</div>')
meta_block = "\n".join(meta_lines)
return f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8">
<style>
{_base_css(t)}
html, body {{ background: {bg}; }}
.page {{ background: {bg}; }}
/* Left sidebar the dominant color element */
.sidebar {{
position: absolute;
top: 0; left: 0;
width: {sidebar_w}px; height: 1123px;
background: {accent};
}}
/* Main content offset from sidebar */
.content {{
position: absolute;
left: {sidebar_w + 52}px; right: 52px;
top: 100px; bottom: 80px;
}}
/* Oversized display title */
.title {{
font-family: '{t['font_display']}', 'Arial Black', Impact, sans-serif;
font-weight: 900;
font-size: 96px;
line-height: 0.92;
color: {dark};
letter-spacing: -0.03em;
text-transform: uppercase;
max-width: 620px;
word-wrap: break-word;
margin-bottom: 22px;
}}
.subtitle {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 12px;
color: {muted};
letter-spacing: 0.05em;
margin-bottom: 0;
}}
/* Thin rule under title area */
.rule {{
width: 64px; height: 2px;
background: {dark};
margin: 24px 0 28px;
}}
/* Author / meta in typewriter font */
.meta-group {{
margin-top: 32px;
}}
.meta-line {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 12px; color: {dark};
line-height: 1.8; letter-spacing: 0.02em;
}}
.meta-role {{
font-family: '{t['font_body']}', 'Courier New', monospace;
color: {muted};
}}
.meta-date {{
font-family: '{t['font_body']}', 'Courier New', monospace;
font-size: 12px; color: {dark};
margin-top: 8px;
}}
/* Right-side content area for thumbnail */
.right-col {{
position: absolute;
right: 52px;
top: 380px; bottom: 80px;
display: flex;
flex-direction: column;
align-items: flex-end;
}}
/* Small accent square icon */
.icon-block {{
width: 64px; height: 64px;
background: {accent};
margin-top: 28px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}}
.icon-lines {{
display: flex; flex-direction: column; gap: 6px;
}}
.icon-line {{
height: 2px; background: {text_l};
}}
</style>
</head>
<body>
<div class="page">
<div class="sidebar"></div>
<div class="content">
<div class="title">{t['title']}</div>
{subtitle_block}
<div class="rule"></div>
<div class="meta-group">{meta_block}</div>
</div>
<div class="right-col">
{image_block}
<div class="icon-block">
<div class="icon-lines">
<div class="icon-line" style="width:32px;"></div>
<div class="icon-line" style="width:24px;"></div>
<div class="icon-line" style="width:28px;"></div>
</div>
</div>
</div>
</div>
</body></html>"""
# ── Dispatch ───────────────────────────────────────────────────────────────────
PATTERNS = {
"fullbleed": _pattern_fullbleed,
"split": _pattern_split,
"typographic": _pattern_typographic,
"atmospheric": _pattern_atmospheric,
"minimal": _pattern_minimal,
"stripe": _pattern_stripe,
"diagonal": _pattern_diagonal,
"frame": _pattern_frame,
"editorial": _pattern_editorial,
"magazine": _pattern_magazine,
"darkroom": _pattern_darkroom,
"terminal": _pattern_terminal,
"poster": _pattern_poster,
}
def render(tokens: dict) -> str:
"""Dispatch to the cover pattern function and return the HTML string."""
pattern = tokens.get("cover_pattern", "fullbleed")
fn = PATTERNS.get(pattern, _pattern_fullbleed)
return fn(tokens)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(description="Render cover HTML from tokens.json")
parser.add_argument("--tokens", default="tokens.json")
parser.add_argument("--out", default="cover.html")
parser.add_argument("--subtitle", default="", help="Optional subtitle override")
args = parser.parse_args()
try:
with open(args.tokens, encoding="utf-8") as f:
tokens = json.load(f)
except FileNotFoundError:
print(json.dumps({"status": "error", "error": f"tokens file not found: {args.tokens}"}),
file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(json.dumps({"status": "error", "error": f"invalid JSON: {e}"}), file=sys.stderr)
sys.exit(1)
if args.subtitle:
tokens["subtitle"] = args.subtitle
html = render(tokens)
try:
with open(args.out, "w", encoding="utf-8") as f:
f.write(html)
except OSError as e:
print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr)
sys.exit(3)
print(json.dumps({
"status": "ok",
"out": args.out,
"pattern": tokens.get("cover_pattern"),
}))
if __name__ == "__main__":
main()