palette.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. #!/usr/bin/env python3
  2. """
  3. palette.py — Infer design tokens from document metadata.
  4. Usage:
  5. python3 palette.py --title "AI Trends 2025" --type report --out tokens.json
  6. python3 palette.py --title "John Doe Resume" --type resume --out tokens.json
  7. python3 palette.py --meta meta.json --out tokens.json
  8. Outputs tokens.json consumed by all downstream scripts.
  9. Cover fonts are loaded via Google Fonts @import in the cover HTML (no local caching).
  10. Body fonts always use ReportLab system fonts (Times-Bold / Helvetica).
  11. Exit codes: 0 success, 1 bad args, 3 write error
  12. """
  13. import argparse
  14. import json
  15. import sys
  16. # ── Palette library ────────────────────────────────────────────────────────────
  17. # Each entry: cover colors + cover_pattern + mood
  18. PALETTES = {
  19. "report": {
  20. # Charcoal blue-grey cover; muted steel blue accent — authoritative, not flashy
  21. "cover_bg": "#1B2A38",
  22. "accent": "#3B6D8A",
  23. "accent_lt": "#E6EFF5",
  24. "text_light": "#EDE9E2",
  25. "page_bg": "#FAFAF8",
  26. "dark": "#1A1E24",
  27. "body_text": "#2C2C30",
  28. "muted": "#7A7A84",
  29. "cover_pattern": "fullbleed",
  30. "mood": "authoritative",
  31. },
  32. "proposal": {
  33. # Dark charcoal cover; slate grey-blue accent — confident, understated
  34. "cover_bg": "#22272E",
  35. "accent": "#4E6070",
  36. "accent_lt": "#EAECEE",
  37. "text_light": "#EDE9E2",
  38. "page_bg": "#FAFAF7",
  39. "dark": "#18191E",
  40. "body_text": "#28282E",
  41. "muted": "#7A7870",
  42. "cover_pattern": "split",
  43. "mood": "confident",
  44. },
  45. "resume": {
  46. # White; deep navy accent — clean and unambiguous
  47. "cover_bg": "#FFFFFF",
  48. "accent": "#1C3557",
  49. "accent_lt": "#E8EEF5",
  50. "text_light": "#FFFFFF",
  51. "page_bg": "#FFFFFF",
  52. "dark": "#111111",
  53. "body_text": "#222222",
  54. "muted": "#888888",
  55. "cover_pattern": "typographic",
  56. "mood": "clean",
  57. },
  58. "portfolio": {
  59. # Near-black charcoal; cool slate grey accent — subdued professional
  60. "cover_bg": "#191C20",
  61. "accent": "#6A7A88",
  62. "accent_lt": "#EAECEE",
  63. "text_light": "#EDE9E4",
  64. "page_bg": "#F8F8F8",
  65. "dark": "#18191E",
  66. "body_text": "#28282E",
  67. "muted": "#8A8A96",
  68. "cover_pattern": "atmospheric",
  69. "mood": "expressive",
  70. },
  71. "academic": {
  72. # Warm white; classic navy accent — scholarly standard
  73. "cover_bg": "#F5F4F0",
  74. "accent": "#2A436A",
  75. "accent_lt": "#E6EBF4",
  76. "text_light": "#FFFFFF",
  77. "page_bg": "#F5F4F0",
  78. "dark": "#1A1A28",
  79. "body_text": "#1E1E2A",
  80. "muted": "#686877",
  81. "cover_pattern": "typographic",
  82. "mood": "scholarly",
  83. },
  84. "general": {
  85. # Dark slate; muted steel accent — neutral, no-nonsense
  86. "cover_bg": "#1F2329",
  87. "accent": "#4A6070",
  88. "accent_lt": "#E6EAEC",
  89. "text_light": "#EEEBE5",
  90. "page_bg": "#F8F6F2",
  91. "dark": "#1A1A1A",
  92. "body_text": "#2C2C2C",
  93. "muted": "#888888",
  94. "cover_pattern": "fullbleed",
  95. "mood": "neutral",
  96. },
  97. # ── Extended types — each uses a distinct new cover pattern ─────────────────
  98. "minimal": {
  99. # Warm off-white; dark neutral grey — truly restrained, no color signal
  100. "cover_bg": "#F7F6F4",
  101. "accent": "#4A4A4A",
  102. "accent_lt": "#EBEBEA",
  103. "text_light": "#F7F6F4",
  104. "page_bg": "#F7F6F4",
  105. "dark": "#111111",
  106. "body_text": "#222222",
  107. "muted": "#999999",
  108. "cover_pattern": "minimal",
  109. "mood": "restrained",
  110. },
  111. "stripe": {
  112. # Near-black; charcoal slate accent — structured, no-nonsense
  113. "cover_bg": "#1E222A",
  114. "accent": "#4A5568",
  115. "accent_lt": "#EAECEE",
  116. "text_light": "#FFFFFF",
  117. "page_bg": "#F8F8F7",
  118. "dark": "#0E1117",
  119. "body_text": "#262630",
  120. "muted": "#888898",
  121. "cover_pattern": "stripe",
  122. "mood": "bold",
  123. },
  124. "diagonal": {
  125. # Deep navy; muted slate-blue accent — dignified, controlled
  126. "cover_bg": "#1A2535",
  127. "accent": "#3D5A72",
  128. "accent_lt": "#E4EBF0",
  129. "text_light": "#EEF0F5",
  130. "page_bg": "#F8FAFC",
  131. "dark": "#0F1A2A",
  132. "body_text": "#1E2C3A",
  133. "muted": "#7A8A96",
  134. "cover_pattern": "diagonal",
  135. "mood": "dynamic",
  136. },
  137. "frame": {
  138. # Warm parchment; dark muted brown — classical, formal
  139. "cover_bg": "#F5F2EC",
  140. "accent": "#5C4A38",
  141. "accent_lt": "#EAE5DE",
  142. "text_light": "#F5F2EC",
  143. "page_bg": "#F5F2EC",
  144. "dark": "#2A1E14",
  145. "body_text": "#2C2018",
  146. "muted": "#9A8A78",
  147. "cover_pattern": "frame",
  148. "mood": "classical",
  149. },
  150. "editorial": {
  151. # White; deep burgundy accent — editorial weight without the shout
  152. "cover_bg": "#FFFFFF",
  153. "accent": "#7A2B36",
  154. "accent_lt": "#EEE4E5",
  155. "text_light": "#FFFFFF",
  156. "page_bg": "#FFFFFF",
  157. "dark": "#0A0A0A",
  158. "body_text": "#1A1A1A",
  159. "muted": "#777777",
  160. "cover_pattern": "editorial",
  161. "mood": "editorial",
  162. },
  163. # ── New patterns (v2) ────────────────────────────────────────────────────────
  164. "magazine": {
  165. # Warm linen; deep navy accent — formal publication standard
  166. "cover_bg": "#F0EEE9",
  167. "accent": "#1C3557",
  168. "accent_lt": "#E4EBF3",
  169. "text_light": "#FFFFFF",
  170. "page_bg": "#F0EEE9",
  171. "dark": "#0D1A2B",
  172. "body_text": "#2A2A2A",
  173. "muted": "#888888",
  174. "cover_pattern": "magazine",
  175. "mood": "magazine",
  176. },
  177. "darkroom": {
  178. # Deep navy; muted steel-blue accent — premium, controlled
  179. "cover_bg": "#151C27",
  180. "accent": "#3D5A7A",
  181. "accent_lt": "#E2EBF2",
  182. "text_light": "#EDE9E2",
  183. "page_bg": "#F7F7F5",
  184. "dark": "#0A1018",
  185. "body_text": "#2C2C2C",
  186. "muted": "#8A9AB0",
  187. "cover_pattern": "darkroom",
  188. "mood": "darkroom",
  189. },
  190. "terminal": {
  191. # Near-black; forest green accent — technical, serious (not neon)
  192. "cover_bg": "#0D1117",
  193. "accent": "#3D7A5C",
  194. "accent_lt": "#E2EEE8",
  195. "text_light": "#E6EDF3",
  196. "page_bg": "#F8F8F6",
  197. "dark": "#010409",
  198. "body_text": "#2C2C2C",
  199. "muted": "#5A7A6A",
  200. "cover_pattern": "terminal",
  201. "mood": "terminal",
  202. },
  203. "poster": {
  204. # White; near-black accent sidebar — stark, unambiguous
  205. "cover_bg": "#FFFFFF",
  206. "accent": "#0A0A0A",
  207. "accent_lt": "#EBEBEA",
  208. "text_light": "#FFFFFF",
  209. "page_bg": "#FFFFFF",
  210. "dark": "#0A0A0A",
  211. "body_text": "#1A1A1A",
  212. "muted": "#888888",
  213. "cover_pattern": "poster",
  214. "mood": "poster",
  215. },
  216. }
  217. # ── Font pairs — CSS names for cover HTML, ReportLab names for body ─────────────
  218. # cover uses Google Fonts via @import (no local disk caching needed)
  219. # body always uses system fonts via ReportLab
  220. FONT_PAIRS = {
  221. "authoritative": {
  222. "display_css": "Playfair Display",
  223. "body_css": "IBM Plex Sans",
  224. "gfonts_import": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=IBM+Plex+Sans:ital,wght@0,400;0,600;1,400&display=swap",
  225. "display_rl": "Times-Bold",
  226. "body_rl": "Helvetica",
  227. "body_b_rl": "Helvetica-Bold",
  228. },
  229. "confident": {
  230. "display_css": "Syne",
  231. "body_css": "Nunito Sans",
  232. "gfonts_import": "https://fonts.googleapis.com/css2?family=Syne:wght@600;800&family=Nunito+Sans:wght@400;600;700&display=swap",
  233. "display_rl": "Times-Bold",
  234. "body_rl": "Helvetica",
  235. "body_b_rl": "Helvetica-Bold",
  236. },
  237. "clean": {
  238. "display_css": "DM Serif Display",
  239. "body_css": "DM Sans",
  240. "gfonts_import": "https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500&display=swap",
  241. "display_rl": "Times-Bold",
  242. "body_rl": "Helvetica",
  243. "body_b_rl": "Helvetica-Bold",
  244. },
  245. "expressive": {
  246. "display_css": "Fraunces",
  247. "body_css": "Inter",
  248. "gfonts_import": "https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,700;0,900;1,900&family=Inter:wght@300;400;500&display=swap",
  249. "display_rl": "Times-Bold",
  250. "body_rl": "Helvetica",
  251. "body_b_rl": "Helvetica-Bold",
  252. },
  253. "scholarly": {
  254. "display_css": "EB Garamond",
  255. "body_css": "Source Sans 3",
  256. "gfonts_import": "https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:wght@400;600&display=swap",
  257. "display_rl": "Times-Bold",
  258. "body_rl": "Helvetica",
  259. "body_b_rl": "Helvetica-Bold",
  260. },
  261. "neutral": {
  262. "display_css": "Outfit",
  263. "body_css": "Outfit",
  264. "gfonts_import": "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;700;900&display=swap",
  265. "display_rl": "Times-Bold",
  266. "body_rl": "Helvetica",
  267. "body_b_rl": "Helvetica-Bold",
  268. },
  269. "restrained": {
  270. "display_css": "Cormorant Garamond",
  271. "body_css": "Jost",
  272. "gfonts_import": "https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,600;1,300&family=Jost:wght@300;400;500&display=swap",
  273. "display_rl": "Times-Bold",
  274. "body_rl": "Helvetica",
  275. "body_b_rl": "Helvetica-Bold",
  276. },
  277. "bold": {
  278. "display_css": "Barlow Condensed",
  279. "body_css": "Barlow",
  280. "gfonts_import": "https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;900&family=Barlow:wght@400;500;600&display=swap",
  281. "display_rl": "Times-Bold",
  282. "body_rl": "Helvetica",
  283. "body_b_rl": "Helvetica-Bold",
  284. },
  285. "dynamic": {
  286. "display_css": "Montserrat",
  287. "body_css": "Montserrat",
  288. "gfonts_import": "https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,700;0,900;1,400&display=swap",
  289. "display_rl": "Times-Bold",
  290. "body_rl": "Helvetica",
  291. "body_b_rl": "Helvetica-Bold",
  292. },
  293. "classical": {
  294. "display_css": "Cormorant",
  295. "body_css": "Crimson Pro",
  296. "gfonts_import": "https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:wght@400;600&display=swap",
  297. "display_rl": "Times-Bold",
  298. "body_rl": "Helvetica",
  299. "body_b_rl": "Helvetica-Bold",
  300. },
  301. "editorial": {
  302. "display_css": "Bebas Neue",
  303. "body_css": "Libre Franklin",
  304. "gfonts_import": (
  305. "https://fonts.googleapis.com/css2?family=Bebas+Neue"
  306. "&family=Libre+Franklin:ital,wght@0,400;0,700;1,400&display=swap"
  307. ),
  308. "display_rl": "Times-Bold",
  309. "body_rl": "Helvetica",
  310. "body_b_rl": "Helvetica-Bold",
  311. },
  312. # ── New moods (v2) ───────────────────────────────────────────────────────────
  313. "magazine": {
  314. "display_css": "Playfair Display",
  315. "body_css": "EB Garamond",
  316. "gfonts_import": (
  317. "https://fonts.googleapis.com/css2?family=Playfair+Display"
  318. ":ital,wght@0,700;0,900;1,700"
  319. "&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap"
  320. ),
  321. "display_rl": "Times-Bold",
  322. "body_rl": "Helvetica",
  323. "body_b_rl": "Helvetica-Bold",
  324. },
  325. "darkroom": {
  326. "display_css": "Playfair Display",
  327. "body_css": "EB Garamond",
  328. "gfonts_import": (
  329. "https://fonts.googleapis.com/css2?family=Playfair+Display"
  330. ":ital,wght@0,700;0,900;1,700"
  331. "&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap"
  332. ),
  333. "display_rl": "Times-Bold",
  334. "body_rl": "Helvetica",
  335. "body_b_rl": "Helvetica-Bold",
  336. },
  337. "terminal": {
  338. "display_css": "Space Mono",
  339. "body_css": "Space Mono",
  340. "gfonts_import": (
  341. "https://fonts.googleapis.com/css2?family=Space+Mono"
  342. ":ital,wght@0,400;0,700;1,400&display=swap"
  343. ),
  344. "display_rl": "Courier-Bold",
  345. "body_rl": "Courier",
  346. "body_b_rl": "Courier-Bold",
  347. },
  348. "poster": {
  349. "display_css": "Barlow Condensed",
  350. "body_css": "Courier Prime",
  351. "gfonts_import": (
  352. "https://fonts.googleapis.com/css2?family=Barlow+Condensed"
  353. ":wght@700;900"
  354. "&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap"
  355. ),
  356. "display_rl": "Times-Bold",
  357. "body_rl": "Courier",
  358. "body_b_rl": "Courier-Bold",
  359. },
  360. }
  361. SYSTEM_FALLBACK = {
  362. "display_css": "Georgia",
  363. "body_css": "Arial",
  364. "gfonts_import": "",
  365. "display_rl": "Times-Bold",
  366. "body_rl": "Helvetica",
  367. "body_b_rl": "Helvetica-Bold",
  368. }
  369. # ── Colour helpers ──────────────────────────────────────────────────────────────
  370. def _hex_to_rgb(h: str) -> tuple:
  371. h = h.lstrip("#")
  372. return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
  373. def _lighten(hex_color: str, factor: float = 0.09) -> str:
  374. """Blend hex_color toward white (factor = accent weight, 0=white, 1=full color)."""
  375. r, g, b = _hex_to_rgb(hex_color)
  376. return "#{:02X}{:02X}{:02X}".format(
  377. round(r * factor + 255 * (1 - factor)),
  378. round(g * factor + 255 * (1 - factor)),
  379. round(b * factor + 255 * (1 - factor)),
  380. )
  381. # ── Token assembly ─────────────────────────────────────────────────────────────
  382. def build_tokens(
  383. title: str,
  384. doc_type: str,
  385. author: str = "",
  386. date: str = "",
  387. accent_override: str = "",
  388. cover_bg_override: str = "",
  389. ) -> dict:
  390. palette = PALETTES.get(doc_type, PALETTES["general"]).copy()
  391. mood = palette["mood"]
  392. font_pair = FONT_PAIRS.get(mood, SYSTEM_FALLBACK)
  393. # Apply caller-supplied overrides before token assembly
  394. if accent_override:
  395. palette["accent"] = accent_override
  396. palette["accent_lt"] = _lighten(accent_override, 0.09)
  397. if cover_bg_override:
  398. palette["cover_bg"] = cover_bg_override
  399. tokens = {
  400. # Identity
  401. "title": title,
  402. "author": author,
  403. "date": date,
  404. "doc_type": doc_type,
  405. # Palette
  406. "cover_bg": palette["cover_bg"],
  407. "accent": palette["accent"],
  408. "accent_lt": palette["accent_lt"],
  409. "text_light": palette["text_light"],
  410. "page_bg": palette["page_bg"],
  411. "dark": palette["dark"],
  412. "body_text": palette["body_text"],
  413. "muted": palette["muted"],
  414. "cover_pattern": palette["cover_pattern"],
  415. "mood": mood,
  416. # Typography — CSS names for cover HTML (loaded via Google Fonts @import)
  417. "font_display": font_pair["display_css"],
  418. "font_body": font_pair["body_css"],
  419. "gfonts_import": font_pair["gfonts_import"],
  420. # Typography — ReportLab system font names for body pages
  421. "font_display_rl": font_pair["display_rl"],
  422. "font_body_rl": font_pair["body_rl"],
  423. "font_body_b_rl": font_pair["body_b_rl"],
  424. # Legacy keys (kept so render_body.py's register_fonts is a no-op)
  425. "font_heading": font_pair["display_rl"],
  426. "font_body_b": font_pair["body_b_rl"],
  427. "font_paths": {},
  428. # Type scale (pt)
  429. "size_display": 54,
  430. "size_h1": 22,
  431. "size_h2": 15,
  432. "size_h3": 11.5,
  433. "size_body": 10.5,
  434. "size_caption": 8.5,
  435. "size_meta": 8,
  436. # Layout (pt, 1cm ≈ 28.35pt)
  437. "margin_left": 79, # 2.8cm
  438. "margin_right": 79,
  439. "margin_top": 79,
  440. "margin_bottom": 71, # 2.5cm
  441. "section_gap": 26,
  442. "para_gap": 8,
  443. "line_gap": 17,
  444. }
  445. return tokens
  446. # ── CLI ───────────────────────────────────────────────────────────────────────
  447. def main():
  448. parser = argparse.ArgumentParser(description="Generate design tokens from document metadata")
  449. parser.add_argument("--title", default="Untitled Document")
  450. parser.add_argument("--type", default="general",
  451. choices=list(PALETTES.keys()),
  452. help="Document type: " + ", ".join(PALETTES.keys()))
  453. parser.add_argument("--author", default="")
  454. parser.add_argument("--date", default="")
  455. parser.add_argument("--meta", help="JSON file with title/type/author/date keys")
  456. parser.add_argument("--accent", default="",
  457. help="Override accent colour (hex, e.g. #2D6A8F). "
  458. "accent_lt is auto-derived by lightening toward white.")
  459. parser.add_argument("--cover-bg", default="",
  460. help="Override cover background colour (hex).")
  461. parser.add_argument("--out", default="tokens.json")
  462. args = parser.parse_args()
  463. if args.meta:
  464. try:
  465. with open(args.meta) as f:
  466. meta = json.load(f)
  467. args.title = meta.get("title", args.title)
  468. args.type = meta.get("type", args.type)
  469. args.author = meta.get("author", args.author)
  470. args.date = meta.get("date", args.date)
  471. except Exception as e:
  472. print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr)
  473. sys.exit(1)
  474. tokens = build_tokens(
  475. args.title, args.type, args.author, args.date,
  476. accent_override=args.accent,
  477. cover_bg_override=getattr(args, "cover_bg", ""),
  478. )
  479. try:
  480. with open(args.out, "w") as f:
  481. json.dump(tokens, f, indent=2)
  482. except Exception as e:
  483. print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr)
  484. sys.exit(3)
  485. print(json.dumps({
  486. "status": "ok",
  487. "out": args.out,
  488. "mood": tokens["mood"],
  489. "pattern": tokens["cover_pattern"],
  490. "fonts": f'{tokens["font_display"]} / {tokens["font_body"]}',
  491. }))
  492. if __name__ == "__main__":
  493. main()