from pathlib import Path import re from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import ( BaseDocTemplate, Frame, PageTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, CondPageBreak, KeepTogether, ) ROOT = Path(__file__).resolve().parent DOCS_DIR = ROOT.parent SOURCE = ROOT / "VOC工作坊学员手册.md" OUTPUT = DOCS_DIR / "VOC工作坊学员手册.pdf" FONT_REGULAR = r"C:\Windows\Fonts\msyh.ttc" FONT_BOLD = r"C:\Windows\Fonts\msyhbd.ttc" FONT_MONO = r"C:\Windows\Fonts\simhei.ttf" def register_fonts(): pdfmetrics.registerFont(TTFont("MSYH", FONT_REGULAR)) pdfmetrics.registerFont(TTFont("MSYH-Bold", FONT_BOLD)) pdfmetrics.registerFont(TTFont("SIMHEI", FONT_MONO)) INK = colors.HexColor("#111318") MUTED = colors.HexColor("#5f6875") LINE = colors.HexColor("#d7dce2") PALE = colors.HexColor("#f5f7f9") PAPER = colors.HexColor("#fbfaf7") CYAN = colors.HexColor("#09b6c8") RED = colors.HexColor("#f04b4b") AMBER = colors.HexColor("#f6a623") GREEN = colors.HexColor("#12a66a") BLUE = colors.HexColor("#315df4") DARK = colors.HexColor("#10141a") def make_styles(): styles = getSampleStyleSheet() styles.add( ParagraphStyle( "CoverKicker", fontName="MSYH-Bold", fontSize=12, leading=16, textColor=CYAN, alignment=TA_LEFT, spaceAfter=8, ) ) styles.add( ParagraphStyle( "CoverTitle", fontName="MSYH-Bold", fontSize=36, leading=43, textColor=colors.white, alignment=TA_LEFT, spaceAfter=16, ) ) styles.add( ParagraphStyle( "CoverSub", fontName="MSYH", fontSize=12.5, leading=20, textColor=colors.HexColor("#cbd5df"), alignment=TA_LEFT, ) ) styles.add( ParagraphStyle( "H1", fontName="MSYH-Bold", fontSize=22, leading=29, textColor=INK, spaceBefore=10, spaceAfter=10, ) ) styles.add( ParagraphStyle( "H2", fontName="MSYH-Bold", fontSize=15, leading=21, textColor=INK, spaceBefore=9, spaceAfter=6, ) ) styles.add( ParagraphStyle( "Body", fontName="MSYH", fontSize=9.3, leading=15, textColor=INK, spaceAfter=5, ) ) styles.add( ParagraphStyle( "Small", fontName="MSYH", fontSize=8, leading=12, textColor=MUTED, ) ) styles.add( ParagraphStyle( "HandbookBullet", fontName="MSYH", fontSize=9.2, leading=14, leftIndent=12, firstLineIndent=-8, textColor=INK, spaceAfter=3, ) ) styles.add( ParagraphStyle( "Quote", fontName="MSYH-Bold", fontSize=12.5, leading=20, textColor=INK, leftIndent=10, borderColor=CYAN, borderWidth=0, borderPadding=0, spaceBefore=6, spaceAfter=8, ) ) styles.add( ParagraphStyle( "PromptCode", fontName="SIMHEI", fontSize=8.2, leading=12, textColor=colors.white, ) ) styles.add( ParagraphStyle( "Cell", fontName="MSYH", fontSize=8, leading=11.5, textColor=INK, ) ) styles.add( ParagraphStyle( "CellHead", fontName="MSYH-Bold", fontSize=8.2, leading=12, textColor=colors.white, alignment=TA_CENTER, ) ) return styles class HandbookDoc(BaseDocTemplate): def __init__(self, filename): super().__init__( filename, pagesize=A4, rightMargin=18 * mm, leftMargin=20 * mm, topMargin=17 * mm, bottomMargin=17 * mm, title="VOC工作坊学员手册", author="VOC.market", ) frame = Frame( self.leftMargin, self.bottomMargin, self.width, self.height, id="normal", ) self.addPageTemplates([PageTemplate(id="normal", frames=[frame], onPage=draw_page)]) def draw_page(canvas, doc): page = canvas.getPageNumber() w, h = A4 if page == 1: draw_cover_bg(canvas, w, h) return canvas.saveState() canvas.setFillColor(PAPER) canvas.rect(0, 0, w, h, fill=1, stroke=0) canvas.setStrokeColor(colors.HexColor("#e5e8ec")) canvas.setLineWidth(0.5) canvas.line(18 * mm, h - 13 * mm, w - 18 * mm, h - 13 * mm) canvas.setFont("MSYH", 7.5) canvas.setFillColor(MUTED) draw_voc_mark(canvas, 20 * mm, h - 11.2 * mm, 5.2 * mm) canvas.drawString(27 * mm, h - 10 * mm, "VOC.MARKET / USER VOICE TO ACTION") canvas.drawRightString(w - 18 * mm, h - 10 * mm, f"{page:02d}") canvas.setStrokeColor(CYAN if page % 3 == 0 else RED if page % 3 == 1 else AMBER) canvas.setLineWidth(2) canvas.line(20 * mm, h - 14 * mm, 45 * mm, h - 14 * mm) canvas.restoreState() def draw_cover_bg(canvas, w, h): canvas.saveState() canvas.setFillColor(DARK) canvas.rect(0, 0, w, h, fill=1, stroke=0) canvas.setStrokeColor(colors.HexColor("#2b323b")) canvas.setLineWidth(0.35) for x in range(0, int(w), 28): canvas.line(x, 0, x, h) for y in range(0, int(h), 28): canvas.line(0, y, w, y) canvas.setFillColor(CYAN) canvas.rect(0, h - 45 * mm, 14 * mm, 45 * mm, fill=1, stroke=0) canvas.setFillColor(RED) canvas.rect(w - 24 * mm, 0, 24 * mm, 75 * mm, fill=1, stroke=0) canvas.setFillColor(AMBER) canvas.rect(w - 78 * mm, 32 * mm, 38 * mm, 8 * mm, fill=1, stroke=0) draw_voc_wordmark(canvas, 20 * mm, h - 34 * mm, 13 * mm, light=True) canvas.setFillColor(colors.HexColor("#202833")) canvas.roundRect(20 * mm, 48 * mm, w - 40 * mm, 40 * mm, 4, fill=1, stroke=0) canvas.setFont("SIMHEI", 8) canvas.setFillColor(colors.HexColor("#aeb8c3")) canvas.drawString(24 * mm, 76 * mm, "OUTPUTS") labels = ["VOC 情报报告", "用户原声证据", "新品机会清单", "门店行动表", "同城内容选题", "老板口播稿"] x = 24 * mm y = 66 * mm for i, label in enumerate(labels): canvas.setFillColor([CYAN, RED, AMBER, GREEN, BLUE, colors.white][i]) canvas.circle(x + (i % 3) * 52 * mm, y - (i // 3) * 12 * mm, 2.2, fill=1, stroke=0) canvas.setFillColor(colors.white) canvas.drawString(x + 5 * mm + (i % 3) * 52 * mm, y - 1.5 - (i // 3) * 12 * mm, label) canvas.restoreState() def draw_voc_mark(canvas, x, y, size): """Vector recreation of the VOC.market favicon from voc.market/www/public/favicon.svg.""" canvas.saveState() # The source SVG uses a cyan-to-red gradient rounded square with a black V. steps = 18 for i in range(steps): t = i / max(steps - 1, 1) r = CYAN.red * (1 - t) + RED.red * t g = CYAN.green * (1 - t) + RED.green * t b = CYAN.blue * (1 - t) + RED.blue * t canvas.setFillColor(colors.Color(r, g, b)) canvas.rect(x + size * i / steps, y, size / steps + 0.2, size, fill=1, stroke=0) canvas.setStrokeColor(colors.Color(1, 1, 1, alpha=0.12)) canvas.roundRect(x, y, size, size, size * 0.20, fill=0, stroke=1) canvas.setFont("MSYH-Bold", size * 0.48) canvas.setFillColor(colors.black) canvas.drawCentredString(x + size / 2, y + size * 0.25, "V") canvas.restoreState() def draw_voc_wordmark(canvas, x, y, size, light=True): draw_voc_mark(canvas, x, y, size) canvas.saveState() canvas.setFont("MSYH-Bold", size * 0.45) canvas.setFillColor(colors.white if light else INK) canvas.drawString(x + size + 4 * mm, y + size * 0.50, "VOC.MARKET") canvas.setFont("MSYH", size * 0.25) canvas.setFillColor(colors.HexColor("#cbd5df") if light else MUTED) canvas.drawString(x + size + 4 * mm, y + size * 0.18, "商协云") canvas.restoreState() def clean_text(text): text = text.replace("&", "&").replace("<", "<").replace(">", ">") text = re.sub(r"`([^`]+)`", r"\1", text) text = text.replace(" ", " ") return text def code_block(lines, styles): body = "
".join(clean_text(line) if line else " " for line in lines) table = Table([[Paragraph(body, styles["PromptCode"])]], colWidths=[165 * mm]) table.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, -1), DARK), ("BOX", (0, 0), (-1, -1), 0.6, colors.HexColor("#2e3742")), ("LEFTPADDING", (0, 0), (-1, -1), 8), ("RIGHTPADDING", (0, 0), (-1, -1), 8), ("TOPPADDING", (0, 0), (-1, -1), 7), ("BOTTOMPADDING", (0, 0), (-1, -1), 7), ] ) ) return table def make_table(rows, styles): if not rows: return Spacer(1, 1) clean_rows = [] for r, row in enumerate(rows): style = styles["CellHead"] if r == 0 else styles["Cell"] clean_rows.append([Paragraph(clean_text(cell.strip()), style) for cell in row]) col_count = max(len(r) for r in rows) widths = [165 * mm / col_count] * col_count table = Table(clean_rows, colWidths=widths, repeatRows=1) table.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), INK), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("BACKGROUND", (0, 1), (-1, -1), colors.white), ("GRID", (0, 0), (-1, -1), 0.45, LINE), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, PALE]), ] ) ) return table def parse_markdown(md, styles): story = [] lines = md.splitlines() in_code = False code_lines = [] table_rows = [] def flush_table(): nonlocal table_rows if table_rows: if len(table_rows) > 1 and all(re.fullmatch(r":?-{2,}:?", c.strip()) for c in table_rows[1]): table_rows.pop(1) story.append(make_table(table_rows, styles)) story.append(Spacer(1, 6)) table_rows = [] def flush_code(): nonlocal code_lines if code_lines: story.append(code_block(code_lines, styles)) story.append(Spacer(1, 6)) code_lines = [] for line in lines: raw = line.rstrip() if raw.startswith("```"): if in_code: flush_code() in_code = False else: flush_table() in_code = True continue if in_code: code_lines.append(raw) continue if raw.startswith("|") and raw.endswith("|"): cells = [c.strip() for c in raw.strip("|").split("|")] table_rows.append(cells) continue flush_table() if raw.strip() == "---": story.append(Spacer(1, 8)) continue if not raw.strip(): story.append(Spacer(1, 3)) continue if raw.startswith("# "): continue if raw.startswith("## "): title = raw[3:].strip() if not title.startswith("0."): story.append(CondPageBreak(72 * mm)) story.append(Paragraph(clean_text(title), styles["H1"])) story.append(section_rule()) continue if raw.startswith("### "): story.append(Paragraph(clean_text(raw[4:].strip()), styles["H2"])) continue if raw.startswith("> "): quote = raw[2:].strip() accent = Table( [[Paragraph(clean_text(quote), styles["Quote"])]], colWidths=[165 * mm], ) accent.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#eefbfc")), ("LINEBEFORE", (0, 0), (0, -1), 4, CYAN), ("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 9), ("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8), ] ) ) story.append(accent) story.append(Spacer(1, 7)) continue if raw.startswith("- "): story.append(Paragraph("• " + clean_text(raw[2:].strip()), styles["HandbookBullet"])) continue if re.match(r"^\d+\.\s+", raw): story.append(Paragraph(clean_text(raw), styles["HandbookBullet"])) continue story.append(Paragraph(clean_text(raw), styles["Body"])) flush_table() flush_code() return story def section_rule(): t = Table([["", "", ""]], colWidths=[30 * mm, 9 * mm, 126 * mm], rowHeights=[2.5 * mm]) t.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (0, 0), CYAN), ("BACKGROUND", (1, 0), (1, 0), RED), ("BACKGROUND", (2, 0), (2, 0), LINE), ("BOTTOMPADDING", (0, 0), (-1, -1), 0), ("TOPPADDING", (0, 0), (-1, -1), 0), ] ) ) return t def cover(styles): return [ Spacer(1, 68 * mm), Paragraph("VOC.MARKET / WORKSHOP HANDBOOK", styles["CoverKicker"]), Paragraph("VOC 工作坊
学员手册", styles["CoverTitle"]), Paragraph( "用 Claude Code + VOC 技能包,把用户真实声音转成新品机会、门店动作和营销内容。", styles["CoverSub"], ), Spacer(1, 10 * mm), Table( [ [ Paragraph("课程时间", styles["CellHead"]), Paragraph("5 月 28 日 14:00-17:00
5 月 29 日 9:00-12:00 / 14:00-17:00", styles["Cell"]), ], [ Paragraph("适用对象", styles["CellHead"]), Paragraph("线下门店老板、品牌负责人、运营负责人", styles["Cell"]), ], [ Paragraph("学习目标", styles["CellHead"]), Paragraph("看懂用户声音、跑通 VOC 技能、输出可执行经营动作", styles["Cell"]), ], ], colWidths=[34 * mm, 125 * mm], ), ] def style_cover_table(flowables): # The last cover object is a table; style it after creation. tbl = flowables[-1] tbl.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#17202b")), ("BACKGROUND", (1, 0), (1, -1), colors.HexColor("#f7f9fb")), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#313b46")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("LEFTPADDING", (0, 0), (-1, -1), 8), ("RIGHTPADDING", (0, 0), (-1, -1), 8), ("TOPPADDING", (0, 0), (-1, -1), 7), ("BOTTOMPADDING", (0, 0), (-1, -1), 7), ] ) ) def build(): register_fonts() styles = make_styles() md = SOURCE.read_text(encoding="utf-8") story = cover(styles) style_cover_table(story) story.append(PageBreak()) story.extend(parse_markdown(md, styles)) doc = HandbookDoc(str(OUTPUT)) doc.build(story) print(OUTPUT) if __name__ == "__main__": build()