| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- 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"<font name='SIMHEI'>\1</font>", text)
- text = text.replace(" ", " ")
- return text
- def code_block(lines, styles):
- body = "<br/>".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 工作坊<br/>学员手册", styles["CoverTitle"]),
- Paragraph(
- "用 Claude Code + VOC 技能包,把用户真实声音转成新品机会、门店动作和营销内容。",
- styles["CoverSub"],
- ),
- Spacer(1, 10 * mm),
- Table(
- [
- [
- Paragraph("课程时间", styles["CellHead"]),
- Paragraph("5 月 28 日 14:00-17:00<br/>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()
|