generate_banner.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. from dataclasses import dataclass
  5. from typing import Dict, List, Sequence
  6. @dataclass(frozen=True)
  7. class BannerInput:
  8. brand: str
  9. version: str | None
  10. repo: str | None
  11. docs: str | None
  12. author: str | None
  13. width: int
  14. color: str
  15. slogan: str | None
  16. hint: str | None
  17. center: bool
  18. rule: bool
  19. glyph: str
  20. color_mode: str
  21. color_start: int
  22. color_end: int
  23. _FONT_5X5: Dict[str, List[str]] = {
  24. "A": [
  25. " XXX ",
  26. "X X",
  27. "XXXXX",
  28. "X X",
  29. "X X",
  30. ],
  31. "B": [
  32. "XXXX ",
  33. "X X",
  34. "XXXX ",
  35. "X X",
  36. "XXXX ",
  37. ],
  38. "C": [
  39. " XXX ",
  40. "X X",
  41. "X ",
  42. "X X",
  43. " XXX ",
  44. ],
  45. "D": [
  46. "XXXX ",
  47. "X X",
  48. "X X",
  49. "X X",
  50. "XXXX ",
  51. ],
  52. "E": [
  53. "XXXXX",
  54. "X ",
  55. "XXXX ",
  56. "X ",
  57. "XXXXX",
  58. ],
  59. "F": [
  60. "XXXXX",
  61. "X ",
  62. "XXXX ",
  63. "X ",
  64. "X ",
  65. ],
  66. "G": [
  67. " XXX ",
  68. "X ",
  69. "X XXX",
  70. "X X",
  71. " XXXX",
  72. ],
  73. "H": [
  74. "X X",
  75. "X X",
  76. "XXXXX",
  77. "X X",
  78. "X X",
  79. ],
  80. "I": [
  81. "XXXXX",
  82. " X ",
  83. " X ",
  84. " X ",
  85. "XXXXX",
  86. ],
  87. "J": [
  88. "XXXXX",
  89. " X ",
  90. " X ",
  91. "X X ",
  92. " XX ",
  93. ],
  94. "K": [
  95. "X X",
  96. "X X ",
  97. "XXX ",
  98. "X X ",
  99. "X X",
  100. ],
  101. "L": [
  102. "X ",
  103. "X ",
  104. "X ",
  105. "X ",
  106. "XXXXX",
  107. ],
  108. "M": [
  109. "X X",
  110. "XX XX",
  111. "X X X",
  112. "X X",
  113. "X X",
  114. ],
  115. "N": [
  116. "X X",
  117. "XX X",
  118. "X X X",
  119. "X XX",
  120. "X X",
  121. ],
  122. "O": [
  123. " XXX ",
  124. "X X",
  125. "X X",
  126. "X X",
  127. " XXX ",
  128. ],
  129. "P": [
  130. "XXXX ",
  131. "X X",
  132. "XXXX ",
  133. "X ",
  134. "X ",
  135. ],
  136. "Q": [
  137. " XXX ",
  138. "X X",
  139. "X X",
  140. "X XX",
  141. " XXXX",
  142. ],
  143. "R": [
  144. "XXXX ",
  145. "X X",
  146. "XXXX ",
  147. "X X ",
  148. "X X",
  149. ],
  150. "S": [
  151. " XXXX",
  152. "X ",
  153. " XXX ",
  154. " X",
  155. "XXXX ",
  156. ],
  157. "T": [
  158. "XXXXX",
  159. " X ",
  160. " X ",
  161. " X ",
  162. " X ",
  163. ],
  164. "U": [
  165. "X X",
  166. "X X",
  167. "X X",
  168. "X X",
  169. " XXX ",
  170. ],
  171. "V": [
  172. "X X",
  173. "X X",
  174. "X X",
  175. " X X ",
  176. " X ",
  177. ],
  178. "W": [
  179. "X X",
  180. "X X",
  181. "X X X",
  182. "XX XX",
  183. "X X",
  184. ],
  185. "X": [
  186. "X X",
  187. " X X ",
  188. " X ",
  189. " X X ",
  190. "X X",
  191. ],
  192. "Y": [
  193. "X X",
  194. " X X ",
  195. " X ",
  196. " X ",
  197. " X ",
  198. ],
  199. "Z": [
  200. "XXXXX",
  201. " X ",
  202. " X ",
  203. " X ",
  204. "XXXXX",
  205. ],
  206. "0": [
  207. " XXX ",
  208. "X X",
  209. "X X",
  210. "X X",
  211. " XXX ",
  212. ],
  213. "1": [
  214. " X ",
  215. " XX ",
  216. " X ",
  217. " X ",
  218. " XXX ",
  219. ],
  220. "2": [
  221. " XXX ",
  222. "X X",
  223. " X ",
  224. " X ",
  225. "XXXXX",
  226. ],
  227. "3": [
  228. "XXXX ",
  229. " X",
  230. " XXX ",
  231. " X",
  232. "XXXX ",
  233. ],
  234. "4": [
  235. "X X",
  236. "X X",
  237. "XXXXX",
  238. " X",
  239. " X",
  240. ],
  241. "5": [
  242. "XXXXX",
  243. "X ",
  244. "XXXX ",
  245. " X",
  246. "XXXX ",
  247. ],
  248. "6": [
  249. " XXX ",
  250. "X ",
  251. "XXXX ",
  252. "X X",
  253. " XXX ",
  254. ],
  255. "7": [
  256. "XXXXX",
  257. " X ",
  258. " X ",
  259. " X ",
  260. "X ",
  261. ],
  262. "8": [
  263. " XXX ",
  264. "X X",
  265. " XXX ",
  266. "X X",
  267. " XXX ",
  268. ],
  269. "9": [
  270. " XXX ",
  271. "X X",
  272. " XXXX",
  273. " X",
  274. " XXX ",
  275. ],
  276. " ": [
  277. " ",
  278. " ",
  279. " ",
  280. " ",
  281. " ",
  282. ],
  283. "-": [
  284. " ",
  285. " ",
  286. "XXXXX",
  287. " ",
  288. " ",
  289. ],
  290. "_": [
  291. " ",
  292. " ",
  293. " ",
  294. " ",
  295. "XXXXX",
  296. ],
  297. ".": [
  298. " ",
  299. " ",
  300. " ",
  301. " ",
  302. " X ",
  303. ],
  304. "'": [
  305. " X ",
  306. " X ",
  307. " ",
  308. " ",
  309. " ",
  310. ],
  311. "!": [
  312. " X ",
  313. " X ",
  314. " X ",
  315. " ",
  316. " X ",
  317. ],
  318. }
  319. def _fill_char(glyph: str) -> str:
  320. if glyph == "block":
  321. return "█"
  322. return "#"
  323. def render_block_text(text: str, glyph: str) -> List[str]:
  324. fill = _fill_char(glyph)
  325. rows = [""] * 5
  326. for ch in text.upper():
  327. g = _FONT_5X5.get(ch, _FONT_5X5[" "])
  328. for i in range(5):
  329. painted = "".join(fill if c != " " else " " for c in g[i])
  330. rows[i] += painted + " "
  331. return [r.rstrip() for r in rows]
  332. def truncate_line(line: str, width: int) -> str:
  333. """截断行内容,确保不超过指定列宽。"""
  334. if width <= 0:
  335. return ""
  336. return line[:width]
  337. def center_line(line: str, width: int) -> str:
  338. if width <= 0:
  339. return ""
  340. s = line.rstrip("\n")
  341. if len(s) >= width:
  342. return s[:width].rstrip()
  343. pad_left = (width - len(s)) // 2
  344. return (" " * pad_left) + s
  345. def _ansi_fg_256(code: int) -> str:
  346. return f"\x1b[38;5;{code}m"
  347. def _ansi_reset() -> str:
  348. return "\x1b[0m"
  349. def _lerp(a: int, b: int, t: float) -> int:
  350. return int(round(a + (b - a) * t))
  351. def colorize_ansi256(lines: List[str], start: int, end: int) -> List[str]:
  352. reset = _ansi_reset()
  353. if not lines:
  354. return []
  355. max_len = max(len(l) for l in lines) or 1
  356. out_lines: List[str] = []
  357. for line in lines:
  358. out = []
  359. for x, ch in enumerate(line):
  360. if ch == " ":
  361. out.append(ch)
  362. continue
  363. t = 0.0 if max_len <= 1 else x / (max_len - 1)
  364. code = _lerp(start, end, t)
  365. out.append(_ansi_fg_256(code) + ch + reset)
  366. out_lines.append("".join(out))
  367. return out_lines
  368. def hr(width: int, ch: str = "-") -> str:
  369. """生成水平分隔线。"""
  370. width = max(0, width)
  371. ch = (ch or "-")[0]
  372. return ch * width
  373. def render_compact(b: BannerInput) -> str:
  374. """渲染窄屏降级 banner。"""
  375. title = b.brand
  376. if b.version:
  377. title += f" (v{b.version})"
  378. lines: List[str] = [truncate_line(title, b.width)]
  379. if b.slogan:
  380. lines.append(center_line(b.slogan, b.width))
  381. if b.hint:
  382. lines.append(center_line(b.hint, b.width))
  383. lines.append(hr(b.width, "-"))
  384. if b.repo:
  385. lines.append(truncate_line(f"Repo: {b.repo}", b.width))
  386. if b.docs:
  387. lines.append(truncate_line(f"Docs: {b.docs}", b.width))
  388. return "\n".join(lines).rstrip()
  389. def render_plain(b: BannerInput) -> str:
  390. """渲染标准 ASCII-only banner。"""
  391. logo_lines = render_block_text(b.brand, b.glyph)
  392. if b.color_mode == "ansi256":
  393. logo_lines = colorize_ansi256(logo_lines, b.color_start, b.color_end)
  394. if b.center:
  395. lines: List[str] = [center_line(truncate_line(l, b.width), b.width) for l in logo_lines]
  396. else:
  397. lines = [truncate_line(l, b.width) for l in logo_lines]
  398. if b.slogan or b.hint:
  399. lines.append("")
  400. if b.slogan:
  401. lines.append(center_line(b.slogan, b.width))
  402. if b.hint:
  403. lines.append("")
  404. lines.append(center_line(b.hint, b.width))
  405. if b.rule:
  406. lines.append("")
  407. lines.append(hr(b.width, "-"))
  408. lines.append(truncate_line(f"Name: {b.brand}", b.width))
  409. if b.version:
  410. lines.append(truncate_line(f"Version: {b.version}", b.width))
  411. if b.author:
  412. lines.append(truncate_line(f"Author: {b.author}", b.width))
  413. if b.repo:
  414. lines.append(truncate_line(f"Repo: {b.repo}", b.width))
  415. if b.docs:
  416. lines.append(truncate_line(f"Docs: {b.docs}", b.width))
  417. return "\n".join(lines).rstrip()
  418. def parse_args(argv: Sequence[str] | None = None) -> BannerInput:
  419. """解析命令行参数并构造 BannerInput。"""
  420. p = argparse.ArgumentParser(description="Generate ASCII-only CLI banner (with compact fallback).")
  421. p.add_argument("--brand", required=True, help="Brand name, e.g. PartmeAI")
  422. p.add_argument("--version", default=None, help="Version, e.g. 1.2.3")
  423. p.add_argument("--repo", default=None, help="Repository URL")
  424. p.add_argument("--docs", default=None, help="Docs URL")
  425. p.add_argument("--author", default=None, help="Author or team")
  426. p.add_argument("--width", type=int, default=80, help="Banner width, default 80")
  427. p.add_argument("--slogan", default=None, help="Centered slogan line under the logo")
  428. p.add_argument("--hint", default=None, help="Centered hint line under the slogan")
  429. p.add_argument("--glyph", default="ascii", choices=["ascii", "block"], help="Logo glyph style: ascii (#) or block (█)")
  430. p.add_argument("--center", dest="center", action="store_true", default=True, help="Center the logo within the width")
  431. p.add_argument("--no-center", dest="center", action="store_false", help="Do not center the logo")
  432. p.add_argument("--rule", dest="rule", action="store_true", default=True, help="Include horizontal rule + info block")
  433. p.add_argument("--no-rule", dest="rule", action="store_false", help="Only output logo + slogan/hint (no info block)")
  434. p.add_argument("--colorMode", default="none", choices=["none", "ansi256"], help="Color mode: none|ansi256 (logo only)")
  435. p.add_argument("--colorStart", type=int, default=33, help="ANSI 256 start color (0-255)")
  436. p.add_argument("--colorEnd", type=int, default=129, help="ANSI 256 end color (0-255)")
  437. ns = p.parse_args(argv)
  438. return BannerInput(
  439. brand=ns.brand,
  440. version=ns.version,
  441. repo=ns.repo,
  442. docs=ns.docs,
  443. author=ns.author,
  444. width=max(10, int(ns.width)),
  445. color="none",
  446. slogan=ns.slogan,
  447. hint=ns.hint,
  448. center=bool(ns.center),
  449. rule=bool(ns.rule),
  450. glyph=ns.glyph,
  451. color_mode=str(ns.colorMode),
  452. color_start=max(0, min(255, int(ns.colorStart))),
  453. color_end=max(0, min(255, int(ns.colorEnd))),
  454. )
  455. def main(argv: Sequence[str] | None = None) -> int:
  456. """程序入口:根据 width 选择标准或 compact 输出。"""
  457. b = parse_args(argv)
  458. if b.width < 60:
  459. print(render_compact(b))
  460. return 0
  461. print(render_plain(b))
  462. return 0
  463. if __name__ == "__main__":
  464. raise SystemExit(main())