cover.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579
  1. #!/usr/bin/env python3
  2. """
  3. cover.py — Generate cover.html from tokens.json.
  4. Usage:
  5. python3 cover.py --tokens tokens.json --out cover.html
  6. Reads tokens.json["cover_pattern"] and renders the matching HTML cover.
  7. Cover fonts are loaded live via Google Fonts @import (no local caching).
  8. Exit codes: 0 success, 1 bad args/missing file, 3 render error
  9. """
  10. import argparse
  11. import json
  12. import sys
  13. # ── Google Fonts loader ────────────────────────────────────────────────────────
  14. def _gfonts_import(t: dict) -> str:
  15. """Return a CSS @import for the document's Google Fonts, if available."""
  16. url = t.get("gfonts_import", "")
  17. if url:
  18. return f"@import url('{url}');"
  19. return ""
  20. # ── Shared CSS head (required by all patterns) ─────────────────────────────────
  21. def _base_css(t: dict) -> str:
  22. """Critical reset + shared variables. Never remove these rules."""
  23. return f"""
  24. {_gfonts_import(t)}
  25. * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  26. html, body {{
  27. width: 794px; height: 1123px;
  28. overflow: hidden;
  29. background: {t['cover_bg']};
  30. font-family: '{t['font_body']}', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  31. }}
  32. .page {{
  33. position: relative;
  34. width: 794px; height: 1123px;
  35. background: {t['cover_bg']};
  36. overflow: hidden;
  37. }}
  38. """
  39. # ── Dot-grid SVG helper ─────────────────────────────────────────────────────────
  40. def _dot_grid(x0, y0, cols, rows, *, gap, r, color, opacity) -> str:
  41. """Render a dot-grid as an absolutely positioned SVG element."""
  42. dots = []
  43. for row in range(rows):
  44. for col in range(cols):
  45. cx = x0 + col * gap
  46. cy = y0 + row * gap
  47. dots.append(f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{color}"/>')
  48. return (
  49. f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
  50. f'pointer-events:none;opacity:{opacity}" xmlns="http://www.w3.org/2000/svg">'
  51. + "".join(dots) + "</svg>"
  52. )
  53. # ── Cross-hatch SVG helper ──────────────────────────────────────────────────────
  54. def _cross_hatch(color, opacity, spacing=32, stroke_w=0.5) -> str:
  55. lines = []
  56. for i in range(-20, 60):
  57. x = i * spacing
  58. lines.append(f'<line x1="{x}" y1="0" x2="{x + 1200}" y2="1200" stroke="{color}" stroke-width="{stroke_w}"/>')
  59. return (
  60. f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
  61. f'pointer-events:none;opacity:{opacity};overflow:hidden" xmlns="http://www.w3.org/2000/svg">'
  62. + "".join(lines) + "</svg>"
  63. )
  64. # ── Pattern 1: Full-bleed block ────────────────────────────────────────────────
  65. def _pattern_fullbleed(t: dict) -> str:
  66. dot_grid = _dot_grid(
  67. x0=500, y0=40, cols=10, rows=20, gap=24, r=1.8,
  68. color=t["accent"], opacity=0.12
  69. )
  70. subtitle_block = ""
  71. if t.get("subtitle"):
  72. subtitle_block = f"""
  73. <div style="font-size:14px;color:{t['muted']};letter-spacing:0.01em;
  74. max-width:480px;line-height:1.5;margin-bottom:40px;">
  75. {t['subtitle']}
  76. </div>"""
  77. return f"""<!DOCTYPE html>
  78. <html>
  79. <head><meta charset="UTF-8">
  80. <style>
  81. {_base_css(t)}
  82. .label {{
  83. font-size: 9px; font-weight: 500; letter-spacing: 0.22em;
  84. color: {t['accent']}; text-transform: uppercase; margin-bottom: 28px;
  85. }}
  86. .title {{
  87. font-family: '{t['font_display']}', 'Times New Roman', Georgia, serif;
  88. font-weight: 900; font-size: 60px; line-height: 1.0;
  89. color: {t['text_light']}; letter-spacing: -0.015em;
  90. margin-bottom: 10px; max-width: 560px;
  91. word-wrap: break-word;
  92. }}
  93. .rule {{
  94. width: 52%; height: 1.5px;
  95. background: linear-gradient(to right, {t['accent']}, transparent);
  96. margin: 24px 0 20px;
  97. }}
  98. .content {{
  99. position: absolute; left: 68px; right: 60px;
  100. top: 0; bottom: 0;
  101. display: flex; flex-direction: column; justify-content: center;
  102. padding-top: 60px;
  103. }}
  104. .footer {{
  105. position: absolute; bottom: 0; left: 0; right: 0;
  106. height: 70px;
  107. background: rgba(0,0,0,0.22);
  108. display: flex; align-items: center;
  109. justify-content: space-between;
  110. padding: 0 68px;
  111. }}
  112. .footer-author {{ font-size: 11px; color: rgba(240,237,230,0.75); letter-spacing:0.04em; }}
  113. .footer-date {{ font-size: 11px; color: {t['muted']}; letter-spacing: 0.04em; }}
  114. </style>
  115. </head>
  116. <body>
  117. <div class="page">
  118. <!-- top-right accent strip -->
  119. <div style="position:absolute;top:0;right:0;width:35%;height:4px;background:{t['accent']};"></div>
  120. <!-- left vertical accent bar (gradient fade) -->
  121. <div style="position:absolute;left:48px;top:18%;width:3px;height:60%;
  122. background:linear-gradient(to bottom,{t['accent']},transparent);"></div>
  123. <!-- dot grid background texture -->
  124. {dot_grid}
  125. <div class="content">
  126. <div class="label">{t.get('doc_type','Document').upper()} &nbsp;·&nbsp; {t.get('date','')}</div>
  127. <div class="title">{t['title']}</div>
  128. <div class="rule"></div>
  129. {subtitle_block}
  130. </div>
  131. <div class="footer">
  132. <div class="footer-author">{t.get('author','')}</div>
  133. <div class="footer-date">{t.get('date','')}</div>
  134. </div>
  135. </div>
  136. </body></html>"""
  137. # ── Pattern 2: Split panel ─────────────────────────────────────────────────────
  138. def _pattern_split(t: dict) -> str:
  139. dot_grid = _dot_grid(
  140. x0=360, y0=120, cols=10, rows=18, gap=22, r=2,
  141. color="#CCCCCC", opacity=0.25
  142. )
  143. return f"""<!DOCTYPE html>
  144. <html>
  145. <head><meta charset="UTF-8">
  146. <style>
  147. {_base_css(t)}
  148. .left-panel {{
  149. position: absolute; top: 0; left: 0;
  150. width: 330px; height: 1123px;
  151. background: {t['cover_bg']};
  152. display: flex; flex-direction: column;
  153. justify-content: center;
  154. padding: 0 44px;
  155. }}
  156. .right-panel {{
  157. position: absolute; top: 0; left: 330px;
  158. width: 464px; height: 1123px;
  159. background: {t['page_bg']};
  160. }}
  161. .divider {{
  162. position: absolute; top: 0; left: 329px;
  163. width: 3px; height: 1123px;
  164. background: {t['accent']};
  165. }}
  166. .left-top-bar {{
  167. position: absolute; top: 0; left: 0;
  168. width: 330px; height: 4px;
  169. background: {t['accent']};
  170. }}
  171. .title {{
  172. font-family: '{t['font_display']}', 'Times New Roman', serif;
  173. font-weight: 900; font-size: 34px; line-height: 1.2;
  174. color: {t['text_light']}; margin-bottom: 18px;
  175. word-wrap: break-word;
  176. }}
  177. .rule {{
  178. width: 55%; height: 1.5px;
  179. background: {t['accent']};
  180. margin-bottom: 14px;
  181. }}
  182. .subtitle {{
  183. font-size: 12px; color: rgba(220,220,220,0.65);
  184. line-height: 1.5; margin-bottom: 32px;
  185. }}
  186. .author {{
  187. font-size: 11px; color: {t['text_light']}; margin-bottom: 4px;
  188. }}
  189. .date {{ font-size: 10px; color: {t['muted']}; }}
  190. .right-label {{
  191. position: absolute; bottom: 60px; right: 44px;
  192. font-size: 9px; letter-spacing: 0.18em;
  193. color: {t['muted']}; text-transform: uppercase;
  194. }}
  195. </style>
  196. </head>
  197. <body>
  198. <div class="page">
  199. <div class="left-top-bar"></div>
  200. <div class="left-panel">
  201. <div class="title">{t['title']}</div>
  202. <div class="rule"></div>
  203. {'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
  204. <div class="author">{t.get('author','')}</div>
  205. <div class="date">{t.get('date','')}</div>
  206. </div>
  207. <div class="right-panel">
  208. {dot_grid}
  209. </div>
  210. <div class="divider"></div>
  211. <div class="right-label">{t.get('doc_type','').upper()}</div>
  212. </div>
  213. </body></html>"""
  214. # ── Pattern 3: Typographic ─────────────────────────────────────────────────────
  215. def _pattern_typographic(t: dict) -> str:
  216. words = t['title'].split()
  217. first = words[0] if words else ""
  218. rest = " ".join(words[1:]) if len(words) > 1 else ""
  219. return f"""<!DOCTYPE html>
  220. <html>
  221. <head><meta charset="UTF-8">
  222. <style>
  223. {_base_css(t)}
  224. html, body {{ background: {t['page_bg']}; }}
  225. .page {{ background: {t['page_bg']}; }}
  226. .content {{
  227. position: absolute; left: 60px; top: 0; bottom: 0; right: 60px;
  228. display: flex; flex-direction: column; justify-content: center;
  229. }}
  230. .first-word {{
  231. font-family: '{t['font_display']}', 'Times New Roman', serif;
  232. font-weight: 900; font-size: 72px; line-height: 1.0;
  233. color: {t['accent']}; letter-spacing: -0.02em;
  234. }}
  235. .rest-words {{
  236. font-family: '{t['font_display']}', 'Times New Roman', serif;
  237. font-weight: 900; font-size: 72px; line-height: 1.0;
  238. color: {t['dark']}; letter-spacing: -0.02em;
  239. margin-bottom: 12px;
  240. }}
  241. .rule {{
  242. width: 100%; height: 1.5px;
  243. background: linear-gradient(to right, {t['accent']}, {t['accent']}40);
  244. margin: 28px 0 20px;
  245. }}
  246. .meta-row {{
  247. display: flex; justify-content: space-between; align-items: baseline;
  248. }}
  249. .author {{ font-size: 13px; color: {t['dark']}; letter-spacing: 0.02em; }}
  250. .date {{ font-size: 12px; color: {t['muted']}; }}
  251. .subtitle {{ font-size: 13px; color: {t['muted']}; margin-top: 8px; max-width: 500px; }}
  252. </style>
  253. </head>
  254. <body>
  255. <div class="page">
  256. <div class="content">
  257. <div class="first-word">{first}</div>
  258. {'<div class="rest-words">' + rest + '</div>' if rest else ''}
  259. <div class="rule"></div>
  260. <div class="meta-row">
  261. <div class="author">{t.get('author','')}</div>
  262. <div class="date">{t.get('date','')}</div>
  263. </div>
  264. {'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
  265. </div>
  266. </div>
  267. </body></html>"""
  268. # ── Pattern 4: Dark atmospheric ────────────────────────────────────────────────
  269. def _pattern_atmospheric(t: dict) -> str:
  270. dot_grid = _dot_grid(
  271. x0=60, y0=60, cols=16, rows=22, gap=20, r=1.5,
  272. color=t["accent"], opacity=0.08
  273. )
  274. return f"""<!DOCTYPE html>
  275. <html>
  276. <head><meta charset="UTF-8">
  277. <style>
  278. {_base_css(t)}
  279. .glow {{
  280. position: absolute;
  281. top: -100px; right: -80px;
  282. width: 500px; height: 500px;
  283. background: radial-gradient(circle, {t['accent']}2E 0%, transparent 68%);
  284. border-radius: 50%;
  285. }}
  286. .glow2 {{
  287. position: absolute;
  288. bottom: -40px; left: 10%;
  289. width: 300px; height: 300px;
  290. background: radial-gradient(circle, {t['accent']}14 0%, transparent 70%);
  291. border-radius: 50%;
  292. }}
  293. .content {{
  294. position: absolute; left: 64px; right: 80px;
  295. top: 0; bottom: 0;
  296. display: flex; flex-direction: column; justify-content: center;
  297. }}
  298. .label {{
  299. font-size: 9px; letter-spacing: 0.22em;
  300. color: {t['accent']}; text-transform: uppercase; margin-bottom: 32px;
  301. }}
  302. .title {{
  303. font-family: '{t['font_display']}', 'Times New Roman', serif;
  304. font-weight: 900; font-size: 50px; line-height: 1.05;
  305. color: {t['text_light']}; max-width: 520px;
  306. word-wrap: break-word; margin-bottom: 12px;
  307. }}
  308. .rule {{ width: 48px; height: 2px; background: {t['accent']}; margin: 24px 0 20px; }}
  309. .subtitle {{
  310. font-size: 13px; color: {t['muted']}; line-height: 1.6;
  311. max-width: 400px; margin-bottom: 40px;
  312. }}
  313. .footer {{
  314. position: absolute; bottom: 0; left: 0; right: 0; height: 64px;
  315. border-top: 1px solid rgba(255,255,255,0.06);
  316. display: flex; align-items: center; justify-content: space-between;
  317. padding: 0 64px;
  318. }}
  319. .footer-l {{ font-size: 10.5px; color: rgba(240,237,230,0.6); }}
  320. .footer-r {{ font-size: 10.5px; color: {t['muted']}; }}
  321. </style>
  322. </head>
  323. <body>
  324. <div class="page">
  325. <div class="glow"></div>
  326. <div class="glow2"></div>
  327. {dot_grid}
  328. <div style="position:absolute;top:0;right:0;width:30%;height:3px;background:{t['accent']};"></div>
  329. <div class="content">
  330. <div class="label">{t.get('doc_type','').upper()} &nbsp;·&nbsp; {t.get('date','')}</div>
  331. <div class="title">{t['title']}</div>
  332. <div class="rule"></div>
  333. {'<div class="subtitle">' + t['subtitle'] + '</div>' if t.get('subtitle') else ''}
  334. </div>
  335. <div class="footer">
  336. <div class="footer-l">{t.get('author','')}</div>
  337. <div class="footer-r">{t.get('date','')}</div>
  338. </div>
  339. </div>
  340. </body></html>"""
  341. # ── Pattern 5: Minimal — thick left bar, generous whitespace ───────────────────
  342. def _pattern_minimal(t: dict) -> str:
  343. """
  344. Ultra-restrained: white background, 8px left accent bar, oversized light-weight
  345. title, nothing else but a hairline rule and minimal metadata. The bar is the only
  346. color on the page — everything else is black on white.
  347. """
  348. # Pick text color for page (minimal uses page_bg which is near-white)
  349. text_dark = t.get("dark", "#111111")
  350. muted = t.get("muted", "#999999")
  351. accent = t["accent"]
  352. subtitle_block = ""
  353. if t.get("subtitle"):
  354. subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
  355. return f"""<!DOCTYPE html>
  356. <html>
  357. <head><meta charset="UTF-8">
  358. <style>
  359. {_base_css(t)}
  360. html, body {{ background: {t['page_bg']}; }}
  361. .page {{ background: {t['page_bg']}; }}
  362. /* Left accent bar — the only color element */
  363. .bar {{
  364. position: absolute;
  365. top: 0; left: 0;
  366. width: 8px; height: 1123px;
  367. background: {accent};
  368. }}
  369. /* Main content column — offset from bar */
  370. .content {{
  371. position: absolute;
  372. left: 64px; right: 64px;
  373. top: 0; bottom: 0;
  374. display: flex;
  375. flex-direction: column;
  376. justify-content: center;
  377. padding-bottom: 40px;
  378. }}
  379. .eyebrow {{
  380. font-size: 9px;
  381. font-weight: 500;
  382. letter-spacing: 0.28em;
  383. text-transform: uppercase;
  384. color: {accent};
  385. margin-bottom: 36px;
  386. }}
  387. .title {{
  388. font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
  389. font-weight: 300;
  390. font-size: 72px;
  391. line-height: 1.0;
  392. color: {text_dark};
  393. letter-spacing: -0.02em;
  394. max-width: 580px;
  395. word-wrap: break-word;
  396. margin-bottom: 0;
  397. }}
  398. .rule {{
  399. width: 56px;
  400. height: 1px;
  401. background: {text_dark};
  402. margin: 36px 0 24px;
  403. opacity: 0.2;
  404. }}
  405. .subtitle {{
  406. font-size: 13px;
  407. font-weight: 300;
  408. color: {muted};
  409. line-height: 1.7;
  410. max-width: 460px;
  411. margin-bottom: 28px;
  412. }}
  413. .meta {{
  414. font-size: 10px;
  415. letter-spacing: 0.06em;
  416. color: {muted};
  417. margin-top: 4px;
  418. }}
  419. </style>
  420. </head>
  421. <body>
  422. <div class="page">
  423. <div class="bar"></div>
  424. <div class="content">
  425. <div class="eyebrow">{t.get('doc_type','').upper()}</div>
  426. <div class="title">{t['title']}</div>
  427. <div class="rule"></div>
  428. {subtitle_block}
  429. <div class="meta">{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}</div>
  430. </div>
  431. </div>
  432. </body></html>"""
  433. # ── Pattern 6: Stripe — bold horizontal bands ──────────────────────────────────
  434. def _pattern_stripe(t: dict) -> str:
  435. """
  436. Page divided into three bold horizontal bands:
  437. - Top band (accent, ~18%): document type label
  438. - Middle band (dark, ~52%): large title in white
  439. - Bottom band (page bg, ~30%): author / date / subtitle
  440. Hard geometry, no gradients, no textures. Newspaper / brand poster aesthetic.
  441. """
  442. top_h = 200 # accent band
  443. mid_h = 580 # dark band
  444. bot_y = top_h + mid_h # 780
  445. accent = t["accent"]
  446. dark = t.get("cover_bg", "#1A1A2E")
  447. light = t.get("page_bg", "#FAFAF8")
  448. text_l = t.get("text_light", "#FFFFFF")
  449. muted = t.get("muted", "#888888")
  450. subtitle_block = ""
  451. if t.get("subtitle"):
  452. subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
  453. return f"""<!DOCTYPE html>
  454. <html>
  455. <head><meta charset="UTF-8">
  456. <style>
  457. {_base_css(t)}
  458. html, body {{ background: {light}; }}
  459. .page {{ background: {light}; }}
  460. /* Three bands */
  461. .band-top {{
  462. position: absolute; top: 0; left: 0;
  463. width: 794px; height: {top_h}px;
  464. background: {accent};
  465. display: flex; align-items: flex-end;
  466. padding: 0 64px 24px;
  467. }}
  468. .band-mid {{
  469. position: absolute; top: {top_h}px; left: 0;
  470. width: 794px; height: {mid_h}px;
  471. background: {dark};
  472. display: flex; flex-direction: column; justify-content: center;
  473. padding: 0 64px;
  474. }}
  475. .band-bot {{
  476. position: absolute; top: {bot_y}px; left: 0;
  477. width: 794px; height: {1123 - bot_y}px;
  478. background: {light};
  479. display: flex; flex-direction: column; justify-content: center;
  480. padding: 0 64px;
  481. }}
  482. /* Top band — doc type in large caps */
  483. .eyebrow {{
  484. font-family: '{t['font_display']}', sans-serif;
  485. font-size: 11px; font-weight: 700;
  486. letter-spacing: 0.32em; text-transform: uppercase;
  487. color: {dark}; opacity: 0.85;
  488. }}
  489. /* Mid band — title */
  490. .title {{
  491. font-family: '{t['font_display']}', 'Times New Roman', Georgia, serif;
  492. font-weight: 900;
  493. font-size: 62px;
  494. line-height: 0.97;
  495. color: {text_l};
  496. letter-spacing: -0.02em;
  497. max-width: 620px;
  498. word-wrap: break-word;
  499. }}
  500. /* Thin horizontal separator between mid and bot */
  501. .sep {{
  502. position: absolute; top: {bot_y}px; left: 0;
  503. width: 794px; height: 2px;
  504. background: {accent};
  505. }}
  506. /* Bottom band */
  507. .author {{
  508. font-size: 13px; font-weight: 500;
  509. color: {t.get('dark','#111')}; margin-bottom: 4px;
  510. }}
  511. .date {{ font-size: 11px; color: {muted}; margin-bottom: 12px; }}
  512. .subtitle {{
  513. font-size: 12px; color: {muted}; line-height: 1.6;
  514. max-width: 540px;
  515. }}
  516. </style>
  517. </head>
  518. <body>
  519. <div class="page">
  520. <div class="band-top">
  521. <div class="eyebrow">{t.get('doc_type','').upper()}</div>
  522. </div>
  523. <div class="band-mid">
  524. <div class="title">{t['title']}</div>
  525. </div>
  526. <div class="sep"></div>
  527. <div class="band-bot">
  528. <div class="author">{t.get('author','')}</div>
  529. <div class="date">{t.get('date','')}</div>
  530. {subtitle_block}
  531. </div>
  532. </div>
  533. </body></html>"""
  534. # ── Pattern 7: Diagonal — angled color split ───────────────────────────────────
  535. def _pattern_diagonal(t: dict) -> str:
  536. """
  537. SVG polygon cuts the page diagonally: upper-left in dark cover color,
  538. lower-right in light page bg. Title sits on the dark area, metadata on light.
  539. One angled edge — no gradients, no curves.
  540. """
  541. dark_bg = t.get("cover_bg", "#1B2A4A")
  542. light_bg = t.get("page_bg", "#FAFCFF")
  543. accent = t["accent"]
  544. text_l = t.get("text_light", "#F8FAFF")
  545. text_d = t.get("dark", "#0F1A2E")
  546. muted = t.get("muted", "#7A8A99")
  547. # Polygon: full upper-left to ~60% down on right side
  548. # Points: top-left, top-right, (794, 620), (0, 820)
  549. poly = "0,0 794,0 794,620 0,820"
  550. subtitle_block = ""
  551. if t.get("subtitle"):
  552. subtitle_block = f'<div class="subtitle-lt">{t["subtitle"]}</div>'
  553. return f"""<!DOCTYPE html>
  554. <html>
  555. <head><meta charset="UTF-8">
  556. <style>
  557. {_base_css(t)}
  558. html, body {{ background: {light_bg}; }}
  559. .page {{ background: {light_bg}; overflow: hidden; }}
  560. /* Title block — upper dark area */
  561. .content-dark {{
  562. position: absolute;
  563. left: 64px; right: 64px;
  564. top: 180px;
  565. z-index: 2;
  566. }}
  567. .eyebrow {{
  568. font-size: 9px; font-weight: 500;
  569. letter-spacing: 0.26em; text-transform: uppercase;
  570. color: {accent}; margin-bottom: 28px;
  571. }}
  572. .title {{
  573. font-family: '{t['font_display']}', 'Helvetica Neue', sans-serif;
  574. font-weight: 900;
  575. font-size: 58px;
  576. line-height: 1.0;
  577. color: {text_l};
  578. letter-spacing: -0.018em;
  579. max-width: 560px;
  580. word-wrap: break-word;
  581. margin-bottom: 16px;
  582. }}
  583. .rule-accent {{
  584. width: 52px; height: 3px;
  585. background: {accent};
  586. margin-top: 28px;
  587. }}
  588. /* Metadata — lower light area */
  589. .content-light {{
  590. position: absolute;
  591. left: 64px; right: 64px;
  592. bottom: 80px;
  593. z-index: 2;
  594. }}
  595. .author {{
  596. font-size: 12px; font-weight: 500;
  597. color: {text_d}; margin-bottom: 4px;
  598. }}
  599. .date {{ font-size: 11px; color: {muted}; margin-bottom: 12px; }}
  600. .subtitle-lt {{
  601. font-size: 12px; color: {muted}; line-height: 1.6;
  602. max-width: 480px;
  603. }}
  604. </style>
  605. </head>
  606. <body>
  607. <div class="page">
  608. <!-- Diagonal dark polygon -->
  609. <svg style="position:absolute;top:0;left:0;width:794px;height:1123px;z-index:1"
  610. xmlns="http://www.w3.org/2000/svg">
  611. <polygon points="{poly}" fill="{dark_bg}"/>
  612. <!-- Accent edge line along the diagonal -->
  613. <line x1="0" y1="820" x2="794" y2="620"
  614. stroke="{accent}" stroke-width="2.5"/>
  615. </svg>
  616. <div class="content-dark">
  617. <div class="eyebrow">{t.get('doc_type','').upper()}&nbsp; · &nbsp;{t.get('date','')}</div>
  618. <div class="title">{t['title']}</div>
  619. <div class="rule-accent"></div>
  620. </div>
  621. <div class="content-light">
  622. <div class="author">{t.get('author','')}</div>
  623. {subtitle_block}
  624. </div>
  625. </div>
  626. </body></html>"""
  627. # ── Pattern 8: Frame — elegant inset border ────────────────────────────────────
  628. def _pattern_frame(t: dict) -> str:
  629. """
  630. Classic formal layout: outer thin border line inset ~28px from page edges,
  631. inner accent strip at top and bottom inside the frame.
  632. Title centered in the frame space, classical serif typography.
  633. Used for: academic papers, formal reports, legal docs, annual reports.
  634. """
  635. bg = t.get("cover_bg", "#FAF8F3")
  636. accent = t["accent"]
  637. dark = t.get("dark", "#2A1A0A")
  638. muted = t.get("muted", "#9A8A78")
  639. pad = 28 # frame inset from page edge
  640. inner_w = 794 - 2 * pad
  641. inner_h = 1123 - 2 * pad
  642. subtitle_block = ""
  643. if t.get("subtitle"):
  644. subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
  645. return f"""<!DOCTYPE html>
  646. <html>
  647. <head><meta charset="UTF-8">
  648. <style>
  649. {_base_css(t)}
  650. html, body {{ background: {bg}; }}
  651. .page {{ background: {bg}; }}
  652. /* Outer frame rectangle */
  653. .frame {{
  654. position: absolute;
  655. top: {pad}px; left: {pad}px;
  656. width: {inner_w}px; height: {inner_h}px;
  657. border: 1.2px solid {dark};
  658. opacity: 0.35;
  659. }}
  660. /* Accent strips inside top and bottom of frame */
  661. .frame-top-accent {{
  662. position: absolute;
  663. top: {pad + 10}px; left: {pad + 10}px;
  664. width: {inner_w - 20}px; height: 3px;
  665. background: {accent};
  666. }}
  667. .frame-bot-accent {{
  668. position: absolute;
  669. bottom: {pad + 10}px; left: {pad + 10}px;
  670. width: {inner_w - 20}px; height: 3px;
  671. background: {accent};
  672. }}
  673. /* Corner ornament squares */
  674. .corner {{
  675. position: absolute;
  676. width: 8px; height: 8px;
  677. background: {accent};
  678. opacity: 0.6;
  679. }}
  680. .tl {{ top: {pad - 4}px; left: {pad - 4}px; }}
  681. .tr {{ top: {pad - 4}px; right: {pad - 4}px; }}
  682. .bl {{ bottom: {pad - 4}px; left: {pad - 4}px; }}
  683. .br {{ bottom: {pad - 4}px; right: {pad - 4}px; }}
  684. /* Main content centered in frame */
  685. .content {{
  686. position: absolute;
  687. left: {pad + 56}px; right: {pad + 56}px;
  688. top: 0; bottom: 0;
  689. display: flex;
  690. flex-direction: column;
  691. align-items: center;
  692. justify-content: center;
  693. text-align: center;
  694. }}
  695. .eyebrow {{
  696. font-size: 8.5px;
  697. font-weight: 500;
  698. letter-spacing: 0.30em;
  699. text-transform: uppercase;
  700. color: {accent};
  701. margin-bottom: 44px;
  702. }}
  703. .rule-top {{
  704. width: 60px; height: 1px;
  705. background: {dark};
  706. opacity: 0.3;
  707. margin-bottom: 28px;
  708. }}
  709. .title {{
  710. font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
  711. font-weight: 400;
  712. font-size: 44px;
  713. line-height: 1.25;
  714. color: {dark};
  715. letter-spacing: 0.01em;
  716. max-width: 540px;
  717. word-wrap: break-word;
  718. margin-bottom: 0;
  719. }}
  720. .rule-mid {{
  721. width: 40px; height: 1.5px;
  722. background: {accent};
  723. margin: 28px 0 20px;
  724. }}
  725. .subtitle {{
  726. font-size: 13px;
  727. font-weight: 300;
  728. font-style: italic;
  729. color: {muted};
  730. line-height: 1.6;
  731. max-width: 400px;
  732. margin-bottom: 20px;
  733. }}
  734. .meta {{
  735. font-size: 10px;
  736. letter-spacing: 0.08em;
  737. color: {muted};
  738. margin-top: 8px;
  739. }}
  740. </style>
  741. </head>
  742. <body>
  743. <div class="page">
  744. <div class="frame"></div>
  745. <div class="frame-top-accent"></div>
  746. <div class="frame-bot-accent"></div>
  747. <div class="corner tl"></div>
  748. <div class="corner tr"></div>
  749. <div class="corner bl"></div>
  750. <div class="corner br"></div>
  751. <div class="content">
  752. <div class="eyebrow">{t.get('doc_type','').upper()}</div>
  753. <div class="rule-top"></div>
  754. <div class="title">{t['title']}</div>
  755. <div class="rule-mid"></div>
  756. {subtitle_block}
  757. <div class="meta">{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}</div>
  758. </div>
  759. </div>
  760. </body></html>"""
  761. # ── Pattern 9: Editorial — oversized ghost letter + bold type ──────────────────
  762. def _pattern_editorial(t: dict) -> str:
  763. """
  764. Magazine / editorial feel:
  765. - Oversized first-letter of title as a ghost background element (8–12% opacity)
  766. - Bold category label at top in accent
  767. - Title in very large condensed weight, flush-left
  768. - Thin full-width rule separating title from metadata
  769. - Author / date bottom-left, page type bottom-right
  770. Designed for editorial reports, annual reviews, magazine-format content.
  771. """
  772. bg = t.get("cover_bg", "#FFFFFF")
  773. accent = t["accent"]
  774. dark = t.get("dark", "#0A0A0A")
  775. muted = t.get("muted", "#777777")
  776. text_l = t.get("text_light", "#FFFFFF")
  777. # Ghost letter — first character of title
  778. ghost = t['title'][0].upper() if t['title'] else "A"
  779. subtitle_block = ""
  780. if t.get("subtitle"):
  781. subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
  782. # Determine if background is dark (use light text) or light (use dark text)
  783. is_dark_bg = (
  784. bg.startswith("#0") or bg.startswith("#1") or bg.startswith("#2")
  785. )
  786. title_color = text_l if is_dark_bg else dark # noqa: F841
  787. body_color = text_l if is_dark_bg else dark
  788. return f"""<!DOCTYPE html>
  789. <html>
  790. <head><meta charset="UTF-8">
  791. <style>
  792. {_base_css(t)}
  793. html, body {{ background: {bg}; }}
  794. .page {{ background: {bg}; }}
  795. /* Ghost letter — background texture */
  796. .ghost {{
  797. position: absolute;
  798. right: -60px; top: -40px;
  799. font-family: '{t['font_display']}', 'Arial Black', sans-serif;
  800. font-weight: 900;
  801. font-size: 680px;
  802. line-height: 1;
  803. color: {dark};
  804. opacity: 0.055;
  805. user-select: none;
  806. letter-spacing: -0.05em;
  807. }}
  808. /* Top bar: accent stripe */
  809. .topbar {{
  810. position: absolute;
  811. top: 0; left: 0; right: 0;
  812. height: 5px;
  813. background: {accent};
  814. }}
  815. /* Category label */
  816. .category {{
  817. position: absolute;
  818. top: 40px; left: 60px;
  819. font-size: 9px; font-weight: 700;
  820. letter-spacing: 0.30em; text-transform: uppercase;
  821. color: {accent};
  822. }}
  823. /* Main title block */
  824. .content {{
  825. position: absolute;
  826. left: 60px; right: 60px;
  827. top: 0; bottom: 0;
  828. display: flex;
  829. flex-direction: column;
  830. justify-content: center;
  831. padding-bottom: 80px;
  832. }}
  833. .title {{
  834. font-family: '{t['font_display']}', 'Arial Black', Impact, sans-serif;
  835. font-weight: 900;
  836. font-size: 80px;
  837. line-height: 0.92;
  838. color: {body_color};
  839. letter-spacing: -0.03em;
  840. max-width: 620px;
  841. word-wrap: break-word;
  842. text-transform: uppercase;
  843. }}
  844. .subtitle {{
  845. font-size: 14px;
  846. font-weight: 400;
  847. color: {muted};
  848. line-height: 1.6;
  849. max-width: 500px;
  850. margin-top: 20px;
  851. }}
  852. /* Full-width rule above footer */
  853. .footer-rule {{
  854. position: absolute;
  855. bottom: 80px; left: 60px; right: 60px;
  856. height: 1px;
  857. background: {body_color};
  858. opacity: 0.15;
  859. }}
  860. /* Footer row */
  861. .footer {{
  862. position: absolute;
  863. bottom: 44px; left: 60px; right: 60px;
  864. display: flex;
  865. justify-content: space-between;
  866. align-items: baseline;
  867. }}
  868. .footer-author {{ font-size: 11px; color: {muted}; letter-spacing: 0.04em; }}
  869. .footer-date {{ font-size: 10px; color: {muted}; letter-spacing: 0.04em; }}
  870. </style>
  871. </head>
  872. <body>
  873. <div class="page">
  874. <div class="ghost">{ghost}</div>
  875. <div class="topbar"></div>
  876. <div class="category">{t.get('doc_type','').upper()}</div>
  877. <div class="content">
  878. <div class="title">{t['title']}</div>
  879. {subtitle_block}
  880. </div>
  881. <div class="footer-rule"></div>
  882. <div class="footer">
  883. <div class="footer-author">{t.get('author','')}</div>
  884. <div class="footer-date">{t.get('date','')}</div>
  885. </div>
  886. </div>
  887. </body></html>"""
  888. # ── Pattern 10: Magazine — elegant centered with optional hero image ────────────
  889. def _pattern_magazine(t: dict) -> str:
  890. """
  891. Upscale centered layout: company name + accent rule at top, large serif title,
  892. decorative rule, italic subtitle, optional hero image, abstract block, author.
  893. Used for: annual reports, strategic documents, formal publications.
  894. """
  895. bg = t.get("cover_bg", "#F2F0EC")
  896. accent = t["accent"]
  897. dark = t.get("dark", "#0D1A2B")
  898. muted = t.get("muted", "#888888")
  899. org = t.get("doc_type", "").upper()
  900. img_url = t.get("cover_image", "")
  901. subtitle_block = ""
  902. if t.get("subtitle"):
  903. subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
  904. image_block = ""
  905. if img_url:
  906. image_block = f"""
  907. <div style="text-align:center;margin:32px 0 28px;">
  908. <img src="{img_url}" style="max-width:340px;max-height:220px;
  909. object-fit:cover;display:inline-block;"/>
  910. </div>"""
  911. abstract_block = ""
  912. if t.get("abstract"):
  913. abstract_block = f"""
  914. <div style="font-size:11px;line-height:1.7;color:{muted};
  915. text-align:justify;max-width:560px;margin:0 auto 0;">
  916. <span style="font-weight:700;color:{accent};">Abstract:</span>
  917. {t['abstract']}
  918. </div>"""
  919. return f"""<!DOCTYPE html>
  920. <html>
  921. <head><meta charset="UTF-8">
  922. <style>
  923. {_base_css(t)}
  924. html, body {{ background: {bg}; }}
  925. .page {{ background: {bg}; display:flex; flex-direction:column;
  926. align-items:center; justify-content:center; padding:60px 80px; }}
  927. .org-name {{
  928. font-size: 9px; font-weight: 500; letter-spacing: 0.30em;
  929. text-transform: uppercase; color: {dark}; text-align:center;
  930. margin-bottom: 10px;
  931. }}
  932. .org-rule {{
  933. width: 56px; height: 2px; background: {accent};
  934. margin: 0 auto 52px;
  935. }}
  936. .title {{
  937. font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
  938. font-weight: 700; font-size: 52px; line-height: 1.08;
  939. color: {dark}; text-align: center; letter-spacing: -0.015em;
  940. max-width: 560px; word-wrap: break-word; margin-bottom: 18px;
  941. }}
  942. .title-rule {{
  943. width: 44px; height: 2.5px; background: {accent};
  944. margin: 0 auto 20px;
  945. }}
  946. .subtitle {{
  947. font-family: '{t['font_display']}', Georgia, serif;
  948. font-style: italic; font-size: 14px; color: {muted};
  949. text-align: center; line-height: 1.5; max-width: 440px;
  950. margin: 0 auto;
  951. }}
  952. .separator {{
  953. width: 100%; max-width: 620px; height: 1px;
  954. background: {dark}; opacity: 0.12;
  955. margin: 28px auto;
  956. }}
  957. .author-name {{
  958. font-family: '{t['font_display']}', Georgia, serif;
  959. font-size: 16px; font-weight: 700; color: {accent};
  960. text-align: center; margin-bottom: 6px;
  961. }}
  962. .date-line {{
  963. font-size: 11px; color: {muted}; text-align: center;
  964. letter-spacing: 0.03em;
  965. }}
  966. </style>
  967. </head>
  968. <body>
  969. <div class="page">
  970. <div class="org-name">{org}</div>
  971. <div class="org-rule"></div>
  972. <div class="title">{t['title']}</div>
  973. <div class="title-rule"></div>
  974. {subtitle_block}
  975. {image_block}
  976. {abstract_block}
  977. {'<div class="separator"></div>' if (t.get('abstract') or img_url) else '<div style="margin:28px 0;"></div>'}
  978. <div class="author-name">{t.get('author','')}</div>
  979. <div class="date-line">{t.get('date','')}</div>
  980. </div>
  981. </body></html>"""
  982. # ── Pattern 11: Darkroom — dark magazine variant ────────────────────────────────
  983. def _pattern_darkroom(t: dict) -> str:
  984. """
  985. Dark-background centered layout. Same structure as magazine but inverted:
  986. deep navy page, white/silver text, accent rules in lighter tone.
  987. Used for: premium reports, tech annual reviews, dark-themed documents.
  988. """
  989. bg = t.get("cover_bg", "#151C27")
  990. accent = t["accent"]
  991. text_l = t.get("text_light", "#F0EDE6")
  992. muted = t.get("muted", "#8A9AB0")
  993. org = t.get("doc_type", "").upper()
  994. img_url = t.get("cover_image", "")
  995. subtitle_block = ""
  996. if t.get("subtitle"):
  997. subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
  998. image_block = ""
  999. if img_url:
  1000. image_block = f"""
  1001. <div style="text-align:center;margin:32px 0 28px;">
  1002. <img src="{img_url}" style="max-width:340px;max-height:220px;
  1003. object-fit:cover;display:inline-block;
  1004. filter:grayscale(20%) brightness(0.9);"/>
  1005. </div>"""
  1006. abstract_block = ""
  1007. if t.get("abstract"):
  1008. abstract_block = f"""
  1009. <div style="font-size:11px;line-height:1.7;color:{muted};
  1010. text-align:justify;max-width:560px;margin:0 auto 0;">
  1011. <span style="font-weight:700;color:{accent};">Abstract:</span>
  1012. {t['abstract']}
  1013. </div>"""
  1014. return f"""<!DOCTYPE html>
  1015. <html>
  1016. <head><meta charset="UTF-8">
  1017. <style>
  1018. {_base_css(t)}
  1019. html, body {{ background: {bg}; }}
  1020. .page {{ background: {bg}; display:flex; flex-direction:column;
  1021. align-items:center; justify-content:center; padding:60px 80px; }}
  1022. .org-name {{
  1023. font-size: 9px; font-weight: 500; letter-spacing: 0.30em;
  1024. text-transform: uppercase; color: {text_l}; text-align:center;
  1025. opacity: 0.75; margin-bottom: 10px;
  1026. }}
  1027. .org-rule {{
  1028. width: 56px; height: 2px; background: {text_l};
  1029. opacity: 0.35; margin: 0 auto 52px;
  1030. }}
  1031. .title {{
  1032. font-family: '{t['font_display']}', Georgia, 'Times New Roman', serif;
  1033. font-weight: 700; font-size: 52px; line-height: 1.08;
  1034. color: {text_l}; text-align: center; letter-spacing: -0.015em;
  1035. max-width: 560px; word-wrap: break-word; margin-bottom: 18px;
  1036. }}
  1037. .title-rule {{
  1038. width: 44px; height: 2.5px; background: {text_l};
  1039. opacity: 0.35; margin: 0 auto 20px;
  1040. }}
  1041. .subtitle {{
  1042. font-family: '{t['font_display']}', Georgia, serif;
  1043. font-style: italic; font-size: 14px; color: {muted};
  1044. text-align: center; line-height: 1.5; max-width: 440px;
  1045. margin: 0 auto;
  1046. }}
  1047. .separator {{
  1048. width: 100%; max-width: 620px; height: 1px;
  1049. background: {text_l}; opacity: 0.12;
  1050. margin: 28px auto;
  1051. }}
  1052. .author-name {{
  1053. font-family: '{t['font_display']}', Georgia, serif;
  1054. font-size: 16px; font-weight: 700; color: {text_l};
  1055. text-align: center; margin-bottom: 6px;
  1056. }}
  1057. .date-line {{
  1058. font-size: 11px; color: {muted}; text-align: center;
  1059. letter-spacing: 0.03em;
  1060. }}
  1061. </style>
  1062. </head>
  1063. <body>
  1064. <div class="page">
  1065. <div class="org-name">{org}</div>
  1066. <div class="org-rule"></div>
  1067. <div class="title">{t['title']}</div>
  1068. <div class="title-rule"></div>
  1069. {subtitle_block}
  1070. {image_block}
  1071. {abstract_block}
  1072. {'<div class="separator"></div>' if (t.get('abstract') or img_url) else '<div style="margin:28px 0;"></div>'}
  1073. <div class="author-name">{t.get('author','')}</div>
  1074. <div class="date-line">{t.get('date','')}</div>
  1075. </div>
  1076. </body></html>"""
  1077. # ── Pattern 12: Terminal — cyber/hacker aesthetic ───────────────────────────────
  1078. def _pattern_terminal(t: dict) -> str:
  1079. """
  1080. Dark terminal/IDE aesthetic: grid overlay, monospace font, neon accent,
  1081. corner brackets around the title block, status bar at bottom.
  1082. Used for: tech reports, developer docs, security audits, system documentation.
  1083. """
  1084. bg = t.get("cover_bg", "#0D1117")
  1085. accent = t["accent"]
  1086. text_l = t.get("text_light", "#E6EDF3")
  1087. muted = t.get("muted", "#48897C")
  1088. dark = t.get("dark", "#010409")
  1089. org = t.get("doc_type", "DOCUMENT").upper()
  1090. date_s = t.get("date", "")
  1091. author = t.get("author", "")
  1092. subtitle_line = ""
  1093. if t.get("subtitle"):
  1094. subtitle_line = f'<div class="subtitle">&gt; {t["subtitle"]}</div>'
  1095. abstract_block = ""
  1096. if t.get("abstract"):
  1097. abstract_block = f"""
  1098. <div class="abstract-text">{t['abstract']}</div>"""
  1099. # grid overlay: horizontal + vertical lines
  1100. h_lines = "".join(
  1101. f'<line x1="0" y1="{y}" x2="794" y2="{y}" stroke="{accent}" stroke-width="0.4"/>'
  1102. for y in range(0, 1124, 48)
  1103. )
  1104. v_lines = "".join(
  1105. f'<line x1="{x}" y1="0" x2="{x}" y2="1123" stroke="{accent}" stroke-width="0.4"/>'
  1106. for x in range(0, 795, 48)
  1107. )
  1108. grid_svg = (
  1109. f'<svg style="position:absolute;top:0;left:0;width:794px;height:1123px;'
  1110. f'pointer-events:none;opacity:0.07" xmlns="http://www.w3.org/2000/svg">'
  1111. + h_lines + v_lines + "</svg>"
  1112. )
  1113. return f"""<!DOCTYPE html>
  1114. <html>
  1115. <head><meta charset="UTF-8">
  1116. <style>
  1117. {_base_css(t)}
  1118. html, body {{ background: {bg}; }}
  1119. .page {{ background: {bg}; }}
  1120. /* Terminal label — top */
  1121. .term-label {{
  1122. position: absolute; top: 44px; left: 56px; right: 56px;
  1123. display: flex; align-items: center; gap: 10px;
  1124. }}
  1125. .dot {{
  1126. width: 8px; height: 8px; border-radius: 50%;
  1127. background: {accent}; flex-shrink: 0;
  1128. }}
  1129. .term-meta {{
  1130. font-family: '{t['font_body']}', 'Courier New', monospace;
  1131. font-size: 10px; color: {accent}; letter-spacing: 0.08em;
  1132. text-transform: uppercase;
  1133. }}
  1134. /* Title bracket block */
  1135. .bracket-block {{
  1136. position: absolute;
  1137. top: 310px; left: 56px; right: 56px;
  1138. border-left: 2px solid {accent}; border-top: 2px solid {accent};
  1139. padding: 24px 28px 28px;
  1140. box-shadow: inset 0 0 0 0;
  1141. }}
  1142. .bracket-block::after {{
  1143. content: '';
  1144. position: absolute;
  1145. bottom: 0; right: 0;
  1146. width: 32px; height: 2px;
  1147. background: {accent};
  1148. }}
  1149. .bracket-block::before {{
  1150. content: '';
  1151. position: absolute;
  1152. bottom: 0; right: 0;
  1153. width: 2px; height: 32px;
  1154. background: {accent};
  1155. }}
  1156. .title {{
  1157. font-family: '{t['font_display']}', 'Courier New', monospace;
  1158. font-weight: 700; font-size: 46px; line-height: 1.05;
  1159. color: {text_l}; letter-spacing: 0.01em;
  1160. text-transform: uppercase;
  1161. word-wrap: break-word; margin-bottom: 16px;
  1162. }}
  1163. .subtitle {{
  1164. font-family: '{t['font_body']}', 'Courier New', monospace;
  1165. font-size: 13px; color: {accent};
  1166. line-height: 1.5; letter-spacing: 0.02em;
  1167. margin-top: 8px;
  1168. }}
  1169. /* Content block below brackets */
  1170. .content-lower {{
  1171. position: absolute;
  1172. top: 640px; left: 56px; right: 56px;
  1173. display: flex; gap: 40px; align-items: flex-start;
  1174. }}
  1175. .abstract-text {{
  1176. font-family: '{t['font_body']}', 'Courier New', monospace;
  1177. font-size: 10.5px; line-height: 1.8; color: {muted};
  1178. flex: 1;
  1179. }}
  1180. .author-block {{
  1181. text-align: right; flex-shrink: 0; min-width: 160px;
  1182. }}
  1183. .author-label {{
  1184. font-family: '{t['font_body']}', monospace;
  1185. font-size: 8px; letter-spacing: 0.20em; color: {muted};
  1186. text-transform: uppercase; margin-bottom: 6px;
  1187. }}
  1188. .author-name {{
  1189. font-family: '{t['font_body']}', monospace;
  1190. font-size: 14px; font-weight: 700; color: {text_l};
  1191. }}
  1192. .author-org {{
  1193. font-family: '{t['font_body']}', monospace;
  1194. font-size: 10px; color: {accent}; margin-top: 4px;
  1195. }}
  1196. /* Bottom status bar */
  1197. .statusbar {{
  1198. position: absolute; bottom: 0; left: 0; right: 0;
  1199. height: 36px; background: {accent}; opacity: 0.12;
  1200. }}
  1201. .statusbar-text {{
  1202. position: absolute; bottom: 0; left: 0; right: 0;
  1203. height: 36px; display: flex; align-items: center;
  1204. justify-content: space-between; padding: 0 56px;
  1205. }}
  1206. .sb-item {{
  1207. font-family: '{t['font_body']}', monospace;
  1208. font-size: 9px; color: {muted}; letter-spacing: 0.12em;
  1209. text-transform: uppercase;
  1210. }}
  1211. </style>
  1212. </head>
  1213. <body>
  1214. <div class="page">
  1215. {grid_svg}
  1216. <div class="term-label">
  1217. <div class="dot"></div>
  1218. <div class="term-meta">SYSTEM_REPORT // {date_s}</div>
  1219. </div>
  1220. <div class="bracket-block">
  1221. <div class="title">{t['title']}</div>
  1222. {subtitle_line}
  1223. </div>
  1224. <div class="content-lower">
  1225. {abstract_block}
  1226. <div class="author-block">
  1227. <div class="author-label">AUTHOR_ID</div>
  1228. <div class="author-name">{author}</div>
  1229. <div class="author-org">{org}</div>
  1230. </div>
  1231. </div>
  1232. <div class="statusbar"></div>
  1233. <div class="statusbar-text">
  1234. <div class="sb-item">Ln 1, Col 1</div>
  1235. <div class="sb-item">UTF-8</div>
  1236. <div class="sb-item">GENERATED_BY_COVERGENIUS</div>
  1237. </div>
  1238. </div>
  1239. </body></html>"""
  1240. # ── Pattern 13: Poster — bold sidebar + oversized type ─────────────────────────
  1241. def _pattern_poster(t: dict) -> str:
  1242. """
  1243. Bold minimalist poster: thick vertical sidebar on the left, oversized all-caps
  1244. title, typewriter-style metadata. Optional thumbnail on the right side.
  1245. Used for: portfolios, creative reports, journalism, photography books.
  1246. """
  1247. bg = t.get("cover_bg", "#FFFFFF")
  1248. accent = t["accent"] # typically black or strong dark
  1249. dark = t.get("dark", "#0A0A0A")
  1250. muted = t.get("muted", "#888888")
  1251. text_l = t.get("text_light", "#FFFFFF")
  1252. img_url = t.get("cover_image", "")
  1253. sidebar_w = 52
  1254. subtitle_block = ""
  1255. if t.get("subtitle"):
  1256. subtitle_block = f'<div class="subtitle">{t["subtitle"]}</div>'
  1257. image_block = ""
  1258. if img_url:
  1259. image_block = f"""
  1260. <img src="{img_url}" style="
  1261. width:260px;height:340px;object-fit:cover;
  1262. display:block;margin-top:32px;
  1263. filter:grayscale(100%) contrast(1.1);"/>"""
  1264. meta_lines = []
  1265. if t.get("author"):
  1266. meta_lines.append(f'<div class="meta-line">{t["author"]}</div>')
  1267. if t.get("subtitle"):
  1268. meta_lines.append(f'<div class="meta-line meta-role">{t["subtitle"]}</div>')
  1269. if t.get("date"):
  1270. meta_lines.append(f'<div class="meta-line meta-date">{t["date"]}</div>')
  1271. meta_block = "\n".join(meta_lines)
  1272. return f"""<!DOCTYPE html>
  1273. <html>
  1274. <head><meta charset="UTF-8">
  1275. <style>
  1276. {_base_css(t)}
  1277. html, body {{ background: {bg}; }}
  1278. .page {{ background: {bg}; }}
  1279. /* Left sidebar — the dominant color element */
  1280. .sidebar {{
  1281. position: absolute;
  1282. top: 0; left: 0;
  1283. width: {sidebar_w}px; height: 1123px;
  1284. background: {accent};
  1285. }}
  1286. /* Main content — offset from sidebar */
  1287. .content {{
  1288. position: absolute;
  1289. left: {sidebar_w + 52}px; right: 52px;
  1290. top: 100px; bottom: 80px;
  1291. }}
  1292. /* Oversized display title */
  1293. .title {{
  1294. font-family: '{t['font_display']}', 'Arial Black', Impact, sans-serif;
  1295. font-weight: 900;
  1296. font-size: 96px;
  1297. line-height: 0.92;
  1298. color: {dark};
  1299. letter-spacing: -0.03em;
  1300. text-transform: uppercase;
  1301. max-width: 620px;
  1302. word-wrap: break-word;
  1303. margin-bottom: 22px;
  1304. }}
  1305. .subtitle {{
  1306. font-family: '{t['font_body']}', 'Courier New', monospace;
  1307. font-size: 12px;
  1308. color: {muted};
  1309. letter-spacing: 0.05em;
  1310. margin-bottom: 0;
  1311. }}
  1312. /* Thin rule under title area */
  1313. .rule {{
  1314. width: 64px; height: 2px;
  1315. background: {dark};
  1316. margin: 24px 0 28px;
  1317. }}
  1318. /* Author / meta in typewriter font */
  1319. .meta-group {{
  1320. margin-top: 32px;
  1321. }}
  1322. .meta-line {{
  1323. font-family: '{t['font_body']}', 'Courier New', monospace;
  1324. font-size: 12px; color: {dark};
  1325. line-height: 1.8; letter-spacing: 0.02em;
  1326. }}
  1327. .meta-role {{
  1328. font-family: '{t['font_body']}', 'Courier New', monospace;
  1329. color: {muted};
  1330. }}
  1331. .meta-date {{
  1332. font-family: '{t['font_body']}', 'Courier New', monospace;
  1333. font-size: 12px; color: {dark};
  1334. margin-top: 8px;
  1335. }}
  1336. /* Right-side content area for thumbnail */
  1337. .right-col {{
  1338. position: absolute;
  1339. right: 52px;
  1340. top: 380px; bottom: 80px;
  1341. display: flex;
  1342. flex-direction: column;
  1343. align-items: flex-end;
  1344. }}
  1345. /* Small accent square icon */
  1346. .icon-block {{
  1347. width: 64px; height: 64px;
  1348. background: {accent};
  1349. margin-top: 28px;
  1350. display: flex; align-items: center; justify-content: center;
  1351. flex-shrink: 0;
  1352. }}
  1353. .icon-lines {{
  1354. display: flex; flex-direction: column; gap: 6px;
  1355. }}
  1356. .icon-line {{
  1357. height: 2px; background: {text_l};
  1358. }}
  1359. </style>
  1360. </head>
  1361. <body>
  1362. <div class="page">
  1363. <div class="sidebar"></div>
  1364. <div class="content">
  1365. <div class="title">{t['title']}</div>
  1366. {subtitle_block}
  1367. <div class="rule"></div>
  1368. <div class="meta-group">{meta_block}</div>
  1369. </div>
  1370. <div class="right-col">
  1371. {image_block}
  1372. <div class="icon-block">
  1373. <div class="icon-lines">
  1374. <div class="icon-line" style="width:32px;"></div>
  1375. <div class="icon-line" style="width:24px;"></div>
  1376. <div class="icon-line" style="width:28px;"></div>
  1377. </div>
  1378. </div>
  1379. </div>
  1380. </div>
  1381. </body></html>"""
  1382. # ── Dispatch ───────────────────────────────────────────────────────────────────
  1383. PATTERNS = {
  1384. "fullbleed": _pattern_fullbleed,
  1385. "split": _pattern_split,
  1386. "typographic": _pattern_typographic,
  1387. "atmospheric": _pattern_atmospheric,
  1388. "minimal": _pattern_minimal,
  1389. "stripe": _pattern_stripe,
  1390. "diagonal": _pattern_diagonal,
  1391. "frame": _pattern_frame,
  1392. "editorial": _pattern_editorial,
  1393. "magazine": _pattern_magazine,
  1394. "darkroom": _pattern_darkroom,
  1395. "terminal": _pattern_terminal,
  1396. "poster": _pattern_poster,
  1397. }
  1398. def render(tokens: dict) -> str:
  1399. """Dispatch to the cover pattern function and return the HTML string."""
  1400. pattern = tokens.get("cover_pattern", "fullbleed")
  1401. fn = PATTERNS.get(pattern, _pattern_fullbleed)
  1402. return fn(tokens)
  1403. # ── CLI ───────────────────────────────────────────────────────────────────────
  1404. def main():
  1405. """CLI entry point."""
  1406. parser = argparse.ArgumentParser(description="Render cover HTML from tokens.json")
  1407. parser.add_argument("--tokens", default="tokens.json")
  1408. parser.add_argument("--out", default="cover.html")
  1409. parser.add_argument("--subtitle", default="", help="Optional subtitle override")
  1410. args = parser.parse_args()
  1411. try:
  1412. with open(args.tokens, encoding="utf-8") as f:
  1413. tokens = json.load(f)
  1414. except FileNotFoundError:
  1415. print(json.dumps({"status": "error", "error": f"tokens file not found: {args.tokens}"}),
  1416. file=sys.stderr)
  1417. sys.exit(1)
  1418. except json.JSONDecodeError as e:
  1419. print(json.dumps({"status": "error", "error": f"invalid JSON: {e}"}), file=sys.stderr)
  1420. sys.exit(1)
  1421. if args.subtitle:
  1422. tokens["subtitle"] = args.subtitle
  1423. html = render(tokens)
  1424. try:
  1425. with open(args.out, "w", encoding="utf-8") as f:
  1426. f.write(html)
  1427. except OSError as e:
  1428. print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr)
  1429. sys.exit(3)
  1430. print(json.dumps({
  1431. "status": "ok",
  1432. "out": args.out,
  1433. "pattern": tokens.get("cover_pattern"),
  1434. }))
  1435. if __name__ == "__main__":
  1436. main()