build_voc_workshop_handbook.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. from pathlib import Path
  2. import re
  3. from reportlab.lib import colors
  4. from reportlab.lib.enums import TA_CENTER, TA_LEFT
  5. from reportlab.lib.pagesizes import A4
  6. from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
  7. from reportlab.lib.units import mm
  8. from reportlab.pdfbase import pdfmetrics
  9. from reportlab.pdfbase.ttfonts import TTFont
  10. from reportlab.platypus import (
  11. BaseDocTemplate,
  12. Frame,
  13. PageTemplate,
  14. Paragraph,
  15. Spacer,
  16. Table,
  17. TableStyle,
  18. PageBreak,
  19. CondPageBreak,
  20. KeepTogether,
  21. )
  22. ROOT = Path(__file__).resolve().parent
  23. DOCS_DIR = ROOT.parent
  24. SOURCE = ROOT / "VOC工作坊学员手册.md"
  25. OUTPUT = DOCS_DIR / "VOC工作坊学员手册.pdf"
  26. FONT_REGULAR = r"C:\Windows\Fonts\msyh.ttc"
  27. FONT_BOLD = r"C:\Windows\Fonts\msyhbd.ttc"
  28. FONT_MONO = r"C:\Windows\Fonts\simhei.ttf"
  29. def register_fonts():
  30. pdfmetrics.registerFont(TTFont("MSYH", FONT_REGULAR))
  31. pdfmetrics.registerFont(TTFont("MSYH-Bold", FONT_BOLD))
  32. pdfmetrics.registerFont(TTFont("SIMHEI", FONT_MONO))
  33. INK = colors.HexColor("#111318")
  34. MUTED = colors.HexColor("#5f6875")
  35. LINE = colors.HexColor("#d7dce2")
  36. PALE = colors.HexColor("#f5f7f9")
  37. PAPER = colors.HexColor("#fbfaf7")
  38. CYAN = colors.HexColor("#09b6c8")
  39. RED = colors.HexColor("#f04b4b")
  40. AMBER = colors.HexColor("#f6a623")
  41. GREEN = colors.HexColor("#12a66a")
  42. BLUE = colors.HexColor("#315df4")
  43. DARK = colors.HexColor("#10141a")
  44. def make_styles():
  45. styles = getSampleStyleSheet()
  46. styles.add(
  47. ParagraphStyle(
  48. "CoverKicker",
  49. fontName="MSYH-Bold",
  50. fontSize=12,
  51. leading=16,
  52. textColor=CYAN,
  53. alignment=TA_LEFT,
  54. spaceAfter=8,
  55. )
  56. )
  57. styles.add(
  58. ParagraphStyle(
  59. "CoverTitle",
  60. fontName="MSYH-Bold",
  61. fontSize=36,
  62. leading=43,
  63. textColor=colors.white,
  64. alignment=TA_LEFT,
  65. spaceAfter=16,
  66. )
  67. )
  68. styles.add(
  69. ParagraphStyle(
  70. "CoverSub",
  71. fontName="MSYH",
  72. fontSize=12.5,
  73. leading=20,
  74. textColor=colors.HexColor("#cbd5df"),
  75. alignment=TA_LEFT,
  76. )
  77. )
  78. styles.add(
  79. ParagraphStyle(
  80. "H1",
  81. fontName="MSYH-Bold",
  82. fontSize=22,
  83. leading=29,
  84. textColor=INK,
  85. spaceBefore=10,
  86. spaceAfter=10,
  87. )
  88. )
  89. styles.add(
  90. ParagraphStyle(
  91. "H2",
  92. fontName="MSYH-Bold",
  93. fontSize=15,
  94. leading=21,
  95. textColor=INK,
  96. spaceBefore=9,
  97. spaceAfter=6,
  98. )
  99. )
  100. styles.add(
  101. ParagraphStyle(
  102. "Body",
  103. fontName="MSYH",
  104. fontSize=9.3,
  105. leading=15,
  106. textColor=INK,
  107. spaceAfter=5,
  108. )
  109. )
  110. styles.add(
  111. ParagraphStyle(
  112. "Small",
  113. fontName="MSYH",
  114. fontSize=8,
  115. leading=12,
  116. textColor=MUTED,
  117. )
  118. )
  119. styles.add(
  120. ParagraphStyle(
  121. "HandbookBullet",
  122. fontName="MSYH",
  123. fontSize=9.2,
  124. leading=14,
  125. leftIndent=12,
  126. firstLineIndent=-8,
  127. textColor=INK,
  128. spaceAfter=3,
  129. )
  130. )
  131. styles.add(
  132. ParagraphStyle(
  133. "Quote",
  134. fontName="MSYH-Bold",
  135. fontSize=12.5,
  136. leading=20,
  137. textColor=INK,
  138. leftIndent=10,
  139. borderColor=CYAN,
  140. borderWidth=0,
  141. borderPadding=0,
  142. spaceBefore=6,
  143. spaceAfter=8,
  144. )
  145. )
  146. styles.add(
  147. ParagraphStyle(
  148. "PromptCode",
  149. fontName="SIMHEI",
  150. fontSize=8.2,
  151. leading=12,
  152. textColor=colors.white,
  153. )
  154. )
  155. styles.add(
  156. ParagraphStyle(
  157. "Cell",
  158. fontName="MSYH",
  159. fontSize=8,
  160. leading=11.5,
  161. textColor=INK,
  162. )
  163. )
  164. styles.add(
  165. ParagraphStyle(
  166. "CellHead",
  167. fontName="MSYH-Bold",
  168. fontSize=8.2,
  169. leading=12,
  170. textColor=colors.white,
  171. alignment=TA_CENTER,
  172. )
  173. )
  174. return styles
  175. class HandbookDoc(BaseDocTemplate):
  176. def __init__(self, filename):
  177. super().__init__(
  178. filename,
  179. pagesize=A4,
  180. rightMargin=18 * mm,
  181. leftMargin=20 * mm,
  182. topMargin=17 * mm,
  183. bottomMargin=17 * mm,
  184. title="VOC工作坊学员手册",
  185. author="VOC.market",
  186. )
  187. frame = Frame(
  188. self.leftMargin,
  189. self.bottomMargin,
  190. self.width,
  191. self.height,
  192. id="normal",
  193. )
  194. self.addPageTemplates([PageTemplate(id="normal", frames=[frame], onPage=draw_page)])
  195. def draw_page(canvas, doc):
  196. page = canvas.getPageNumber()
  197. w, h = A4
  198. if page == 1:
  199. draw_cover_bg(canvas, w, h)
  200. return
  201. canvas.saveState()
  202. canvas.setFillColor(PAPER)
  203. canvas.rect(0, 0, w, h, fill=1, stroke=0)
  204. canvas.setStrokeColor(colors.HexColor("#e5e8ec"))
  205. canvas.setLineWidth(0.5)
  206. canvas.line(18 * mm, h - 13 * mm, w - 18 * mm, h - 13 * mm)
  207. canvas.setFont("MSYH", 7.5)
  208. canvas.setFillColor(MUTED)
  209. draw_voc_mark(canvas, 20 * mm, h - 11.2 * mm, 5.2 * mm)
  210. canvas.drawString(27 * mm, h - 10 * mm, "VOC.MARKET / USER VOICE TO ACTION")
  211. canvas.drawRightString(w - 18 * mm, h - 10 * mm, f"{page:02d}")
  212. canvas.setStrokeColor(CYAN if page % 3 == 0 else RED if page % 3 == 1 else AMBER)
  213. canvas.setLineWidth(2)
  214. canvas.line(20 * mm, h - 14 * mm, 45 * mm, h - 14 * mm)
  215. canvas.restoreState()
  216. def draw_cover_bg(canvas, w, h):
  217. canvas.saveState()
  218. canvas.setFillColor(DARK)
  219. canvas.rect(0, 0, w, h, fill=1, stroke=0)
  220. canvas.setStrokeColor(colors.HexColor("#2b323b"))
  221. canvas.setLineWidth(0.35)
  222. for x in range(0, int(w), 28):
  223. canvas.line(x, 0, x, h)
  224. for y in range(0, int(h), 28):
  225. canvas.line(0, y, w, y)
  226. canvas.setFillColor(CYAN)
  227. canvas.rect(0, h - 45 * mm, 14 * mm, 45 * mm, fill=1, stroke=0)
  228. canvas.setFillColor(RED)
  229. canvas.rect(w - 24 * mm, 0, 24 * mm, 75 * mm, fill=1, stroke=0)
  230. canvas.setFillColor(AMBER)
  231. canvas.rect(w - 78 * mm, 32 * mm, 38 * mm, 8 * mm, fill=1, stroke=0)
  232. draw_voc_wordmark(canvas, 20 * mm, h - 34 * mm, 13 * mm, light=True)
  233. canvas.setFillColor(colors.HexColor("#202833"))
  234. canvas.roundRect(20 * mm, 48 * mm, w - 40 * mm, 40 * mm, 4, fill=1, stroke=0)
  235. canvas.setFont("SIMHEI", 8)
  236. canvas.setFillColor(colors.HexColor("#aeb8c3"))
  237. canvas.drawString(24 * mm, 76 * mm, "OUTPUTS")
  238. labels = ["VOC 情报报告", "用户原声证据", "新品机会清单", "门店行动表", "同城内容选题", "老板口播稿"]
  239. x = 24 * mm
  240. y = 66 * mm
  241. for i, label in enumerate(labels):
  242. canvas.setFillColor([CYAN, RED, AMBER, GREEN, BLUE, colors.white][i])
  243. canvas.circle(x + (i % 3) * 52 * mm, y - (i // 3) * 12 * mm, 2.2, fill=1, stroke=0)
  244. canvas.setFillColor(colors.white)
  245. canvas.drawString(x + 5 * mm + (i % 3) * 52 * mm, y - 1.5 - (i // 3) * 12 * mm, label)
  246. canvas.restoreState()
  247. def draw_voc_mark(canvas, x, y, size):
  248. """Vector recreation of the VOC.market favicon from voc.market/www/public/favicon.svg."""
  249. canvas.saveState()
  250. # The source SVG uses a cyan-to-red gradient rounded square with a black V.
  251. steps = 18
  252. for i in range(steps):
  253. t = i / max(steps - 1, 1)
  254. r = CYAN.red * (1 - t) + RED.red * t
  255. g = CYAN.green * (1 - t) + RED.green * t
  256. b = CYAN.blue * (1 - t) + RED.blue * t
  257. canvas.setFillColor(colors.Color(r, g, b))
  258. canvas.rect(x + size * i / steps, y, size / steps + 0.2, size, fill=1, stroke=0)
  259. canvas.setStrokeColor(colors.Color(1, 1, 1, alpha=0.12))
  260. canvas.roundRect(x, y, size, size, size * 0.20, fill=0, stroke=1)
  261. canvas.setFont("MSYH-Bold", size * 0.48)
  262. canvas.setFillColor(colors.black)
  263. canvas.drawCentredString(x + size / 2, y + size * 0.25, "V")
  264. canvas.restoreState()
  265. def draw_voc_wordmark(canvas, x, y, size, light=True):
  266. draw_voc_mark(canvas, x, y, size)
  267. canvas.saveState()
  268. canvas.setFont("MSYH-Bold", size * 0.45)
  269. canvas.setFillColor(colors.white if light else INK)
  270. canvas.drawString(x + size + 4 * mm, y + size * 0.50, "VOC.MARKET")
  271. canvas.setFont("MSYH", size * 0.25)
  272. canvas.setFillColor(colors.HexColor("#cbd5df") if light else MUTED)
  273. canvas.drawString(x + size + 4 * mm, y + size * 0.18, "商协云")
  274. canvas.restoreState()
  275. def clean_text(text):
  276. text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
  277. text = re.sub(r"`([^`]+)`", r"<font name='SIMHEI'>\1</font>", text)
  278. text = text.replace(" ", " ")
  279. return text
  280. def code_block(lines, styles):
  281. body = "<br/>".join(clean_text(line) if line else "&nbsp;" for line in lines)
  282. table = Table([[Paragraph(body, styles["PromptCode"])]], colWidths=[165 * mm])
  283. table.setStyle(
  284. TableStyle(
  285. [
  286. ("BACKGROUND", (0, 0), (-1, -1), DARK),
  287. ("BOX", (0, 0), (-1, -1), 0.6, colors.HexColor("#2e3742")),
  288. ("LEFTPADDING", (0, 0), (-1, -1), 8),
  289. ("RIGHTPADDING", (0, 0), (-1, -1), 8),
  290. ("TOPPADDING", (0, 0), (-1, -1), 7),
  291. ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
  292. ]
  293. )
  294. )
  295. return table
  296. def make_table(rows, styles):
  297. if not rows:
  298. return Spacer(1, 1)
  299. clean_rows = []
  300. for r, row in enumerate(rows):
  301. style = styles["CellHead"] if r == 0 else styles["Cell"]
  302. clean_rows.append([Paragraph(clean_text(cell.strip()), style) for cell in row])
  303. col_count = max(len(r) for r in rows)
  304. widths = [165 * mm / col_count] * col_count
  305. table = Table(clean_rows, colWidths=widths, repeatRows=1)
  306. table.setStyle(
  307. TableStyle(
  308. [
  309. ("BACKGROUND", (0, 0), (-1, 0), INK),
  310. ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
  311. ("BACKGROUND", (0, 1), (-1, -1), colors.white),
  312. ("GRID", (0, 0), (-1, -1), 0.45, LINE),
  313. ("VALIGN", (0, 0), (-1, -1), "TOP"),
  314. ("LEFTPADDING", (0, 0), (-1, -1), 5),
  315. ("RIGHTPADDING", (0, 0), (-1, -1), 5),
  316. ("TOPPADDING", (0, 0), (-1, -1), 5),
  317. ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
  318. ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, PALE]),
  319. ]
  320. )
  321. )
  322. return table
  323. def parse_markdown(md, styles):
  324. story = []
  325. lines = md.splitlines()
  326. in_code = False
  327. code_lines = []
  328. table_rows = []
  329. def flush_table():
  330. nonlocal table_rows
  331. if table_rows:
  332. if len(table_rows) > 1 and all(re.fullmatch(r":?-{2,}:?", c.strip()) for c in table_rows[1]):
  333. table_rows.pop(1)
  334. story.append(make_table(table_rows, styles))
  335. story.append(Spacer(1, 6))
  336. table_rows = []
  337. def flush_code():
  338. nonlocal code_lines
  339. if code_lines:
  340. story.append(code_block(code_lines, styles))
  341. story.append(Spacer(1, 6))
  342. code_lines = []
  343. for line in lines:
  344. raw = line.rstrip()
  345. if raw.startswith("```"):
  346. if in_code:
  347. flush_code()
  348. in_code = False
  349. else:
  350. flush_table()
  351. in_code = True
  352. continue
  353. if in_code:
  354. code_lines.append(raw)
  355. continue
  356. if raw.startswith("|") and raw.endswith("|"):
  357. cells = [c.strip() for c in raw.strip("|").split("|")]
  358. table_rows.append(cells)
  359. continue
  360. flush_table()
  361. if raw.strip() == "---":
  362. story.append(Spacer(1, 8))
  363. continue
  364. if not raw.strip():
  365. story.append(Spacer(1, 3))
  366. continue
  367. if raw.startswith("# "):
  368. continue
  369. if raw.startswith("## "):
  370. title = raw[3:].strip()
  371. if not title.startswith("0."):
  372. story.append(CondPageBreak(72 * mm))
  373. story.append(Paragraph(clean_text(title), styles["H1"]))
  374. story.append(section_rule())
  375. continue
  376. if raw.startswith("### "):
  377. story.append(Paragraph(clean_text(raw[4:].strip()), styles["H2"]))
  378. continue
  379. if raw.startswith("> "):
  380. quote = raw[2:].strip()
  381. accent = Table(
  382. [[Paragraph(clean_text(quote), styles["Quote"])]],
  383. colWidths=[165 * mm],
  384. )
  385. accent.setStyle(
  386. TableStyle(
  387. [
  388. ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#eefbfc")),
  389. ("LINEBEFORE", (0, 0), (0, -1), 4, CYAN),
  390. ("LEFTPADDING", (0, 0), (-1, -1), 10),
  391. ("RIGHTPADDING", (0, 0), (-1, -1), 9),
  392. ("TOPPADDING", (0, 0), (-1, -1), 8),
  393. ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
  394. ]
  395. )
  396. )
  397. story.append(accent)
  398. story.append(Spacer(1, 7))
  399. continue
  400. if raw.startswith("- "):
  401. story.append(Paragraph("• " + clean_text(raw[2:].strip()), styles["HandbookBullet"]))
  402. continue
  403. if re.match(r"^\d+\.\s+", raw):
  404. story.append(Paragraph(clean_text(raw), styles["HandbookBullet"]))
  405. continue
  406. story.append(Paragraph(clean_text(raw), styles["Body"]))
  407. flush_table()
  408. flush_code()
  409. return story
  410. def section_rule():
  411. t = Table([["", "", ""]], colWidths=[30 * mm, 9 * mm, 126 * mm], rowHeights=[2.5 * mm])
  412. t.setStyle(
  413. TableStyle(
  414. [
  415. ("BACKGROUND", (0, 0), (0, 0), CYAN),
  416. ("BACKGROUND", (1, 0), (1, 0), RED),
  417. ("BACKGROUND", (2, 0), (2, 0), LINE),
  418. ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
  419. ("TOPPADDING", (0, 0), (-1, -1), 0),
  420. ]
  421. )
  422. )
  423. return t
  424. def cover(styles):
  425. return [
  426. Spacer(1, 68 * mm),
  427. Paragraph("VOC.MARKET / WORKSHOP HANDBOOK", styles["CoverKicker"]),
  428. Paragraph("VOC 工作坊<br/>学员手册", styles["CoverTitle"]),
  429. Paragraph(
  430. "用 Claude Code + VOC 技能包,把用户真实声音转成新品机会、门店动作和营销内容。",
  431. styles["CoverSub"],
  432. ),
  433. Spacer(1, 10 * mm),
  434. Table(
  435. [
  436. [
  437. Paragraph("课程时间", styles["CellHead"]),
  438. Paragraph("5 月 28 日 14:00-17:00<br/>5 月 29 日 9:00-12:00 / 14:00-17:00", styles["Cell"]),
  439. ],
  440. [
  441. Paragraph("适用对象", styles["CellHead"]),
  442. Paragraph("线下门店老板、品牌负责人、运营负责人", styles["Cell"]),
  443. ],
  444. [
  445. Paragraph("学习目标", styles["CellHead"]),
  446. Paragraph("看懂用户声音、跑通 VOC 技能、输出可执行经营动作", styles["Cell"]),
  447. ],
  448. ],
  449. colWidths=[34 * mm, 125 * mm],
  450. ),
  451. ]
  452. def style_cover_table(flowables):
  453. # The last cover object is a table; style it after creation.
  454. tbl = flowables[-1]
  455. tbl.setStyle(
  456. TableStyle(
  457. [
  458. ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#17202b")),
  459. ("BACKGROUND", (1, 0), (1, -1), colors.HexColor("#f7f9fb")),
  460. ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#313b46")),
  461. ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
  462. ("LEFTPADDING", (0, 0), (-1, -1), 8),
  463. ("RIGHTPADDING", (0, 0), (-1, -1), 8),
  464. ("TOPPADDING", (0, 0), (-1, -1), 7),
  465. ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
  466. ]
  467. )
  468. )
  469. def build():
  470. register_fonts()
  471. styles = make_styles()
  472. md = SOURCE.read_text(encoding="utf-8")
  473. story = cover(styles)
  474. style_cover_table(story)
  475. story.append(PageBreak())
  476. story.extend(parse_markdown(md, styles))
  477. doc = HandbookDoc(str(OUTPUT))
  478. doc.build(story)
  479. print(OUTPUT)
  480. if __name__ == "__main__":
  481. build()