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()