openpencil/templates/step0/_generators/tpl_pitch_dark.py
Fini 6fd0b343a1 fix(editor): tighten template copy that wrapped into orphan lines
A CJK line-break gate over all fifty-eight templates caught eleven
sets whose body copy wrapped into three-character orphan tails or
punctuation-led lines. Each line is shortened to fit its measured
column (paired sides stay symmetric where the layout demands it),
affected root heights re-measured, previews re-baked. The one
remaining gate report is a simulator false positive disproven by the
rendered frame.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-09 02:24:44 +08:00

474 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""pitch-deck-dark.op — 深色路演 deck封面 / 问题 / 方案 / 数据 / 里程碑 / CTA
已有的 slide-deck 是暖白商务风,适合汇报;这一套是它的反面:黑底、单强调
色、大字号、少而重的信息。路演是「在别人只肯给你八分钟的房间里,把一件
事讲到对方想追问」——所以每页只有一个视觉锚点,其余全部让位。
这一版把「深色」从「一块死黑」做成有层次的深色:
1. **底是对角微光渐变**,不是纯色。近黑的石墨蓝 → 略偏蓝的深靛 → 回到近
135° 铺满整页。投影时这一点点方向感就是「专业深色」与「PPT 默认
黑底」的差别。
2. **章节页有 ghost 大数字**。480px 的页码压在内容之下(`oplib.stack` 的
装饰层),用强调色 + 6% 图层透明度 —— 不是把颜色调灰,是把整层调淡,
所以换强调色时它自己跟着走。
3. **强调色做成霓虹细条**。卡首那道线从强调色渐到全透明,像一条点亮的
边,而不是一根填色矩形。
4. **字阶拉开**:封面 128 / 正文 304.3 倍),页标题 76 / 正文 28。展示字
统一收紧字距到 -2 ~ -3。
排版遵循 skills/domains/slides.md 的硬契约:
- 每帧固定 1920×1080绝不 fit_content内容距边缘 ≥100这里 120
- 正文 ≥24取 26-30标题 ≥40关键数字 80-200数据页取 148
- 行高:展示字 1.06-1.15,正文 1.4+
- 最多 2 个字体族Noto Sans SC 排中文Inter 排数字),靠字重分层
- 强调色只用于强调;正文一律中性色
深底上的对比度WCAG实测见文件末尾最低一对是 4.73:1全部高于 4.5。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, color_vars, frame, linear, radial, rect, solid, stack,
stroke, text, write_doc)
ids = Ids()
# 近黑的石墨蓝 + 一个电光蓝。深色 deck 最容易翻车的地方是「深色 = 一堆
# 灰」所以底色只有两级bg / surface层级靠字重和强调色拉不靠再多
# 铺几层灰面。
VARS = color_vars({
"c-bg": "#0B1220",
# 页面渐变的中段与末段。跨度刻意小(亮度差不到 4%):再大一点,页与页
# 之间就会看出「这页比那页亮」,一整套投下来像没校准的投影仪。
"c-bg-mid": "#111C33",
"c-bg-end": "#0A1424",
"c-surface": "#18263F",
"c-ink": "#F2F6FF",
"c-muted": "#93A4C4",
"c-accent": "#4D8DFF",
"c-accent-soft": "#16233D",
"c-border": "#24314D",
# 光晕与霓虹细条的两端。带 alpha —— 它们是文字的**兄弟**不是祖先,
# lint 找底色只走祖先链,碰不到这两个值;而渐到全透明是唯一能让光晕在
# 渐变底上不露硬边的写法。
"c-glow": "#4D8DFF29",
"c-glow-out": "#4D8DFF00",
})
W, H = 1920, 1080
EDGE = 120
# 中文字族写死oplib 的默认族按字号/字重挑(<34 且 <600 走 Inter那条
# 规则是给中英混排写的;这套 deck 是纯中文正文,小字号也必须落在有汉字的
# 字族上,否则要靠 fallback 兜。
CJK = "Noto Sans SC"
# 数字、单位符号走 Inter等宽感更强KPI 排出来才齐。
NUM = "Inter"
# 135° = 左上 → 右下(`.op` 的角度约定0° 自下而上、90° 自左向右,见
# op-host-native/src/backend/skia/gradient.rs
PAGE_ANGLE = 135
def page_fill():
return linear(PAGE_ANGLE, [(0.0, "$c-bg"), (0.55, "$c-bg-mid"),
(1.0, "$c-bg-end")])
def slide(name, *, gap=56, justify="start", decor=()):
"""一帧幻灯片。固定 1920×1080 —— 投影比例是硬约束,不是建议。"""
body = frame(ids, f"{name} · 正文", width="fill_container",
height="fill_container", layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=[])
body["children"] = []
shell = stack(ids, name, body, list(decor), width=W, height=H,
fill=page_fill())
shell["content"] = body
return shell
def glow(size, x, y):
"""一团光晕。渐到全透明,所以压在渐变底上也不会露出圆形硬边。"""
node = rect(ids, "光晕", width=size, height=size, cornerRadius=size // 2,
fill=radial([(0.0, "$c-glow"), (1.0, "$c-glow-out")]))
node["x"], node["y"] = x, y
return node
def ghost_number(label, x, y, *, size=480):
"""章节页背景上的巨大页码。
做法是「满色 + 图层透明度」而不是「把颜色调到接近底色」:前者换强调色
时自己跟着走,后者要手算一遍;而且 lint 量的是文字的原色(它不看节点
opacity满色写法不会在检测器里留下一条假的低对比记录。
"""
node = text(ids, "ghost 页码", label, size, 700, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0,
spacing=-round(size * 0.05))
node["x"], node["y"] = x, y
node["opacity"] = 0.07
return node
def col(name, children, *, gap=16, width="fill_container",
height="fit_content", align="start", fill=None, **props):
"""纵向容器。`fill=None` 才是结构层;一旦有 fill 它就是一张卡面了。"""
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
def row(name, children, *, gap=40, align="start", width="fill_container",
height="fit_content", fill=None, **props):
node = frame(ids, name, width=width, height=height, layout="horizontal",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
def neon_rule(width=72, height=4):
"""霓虹细条。强调色渐到透明,读起来像一段被点亮的边而不是一块填色。"""
return rect(ids, "霓虹线", width=width, height=height, cornerRadius=999,
fill=linear(90, [(0.0, "$c-accent"), (1.0, "$c-glow-out")]))
def eyebrow(label):
"""小标签。整套 deck 唯一的胶囊,用来交代「这是什么场合」。"""
node = frame(ids, "标签", width="fit_content", height="fit_content",
layout="horizontal", padding=[10, 22], gap=0,
cornerRadius=999, alignItems="center",
justifyContent="center", fill=solid("$c-accent-soft"))
node["children"] = [
text(ids, "标签文字", label, 26, 600, "$c-accent", family=CJK,
width="fit_content", growth="auto", line_height=1.4,
spacing=1.0),
]
return node
def accent_bar(width=160, height=8):
return rect(ids, "强调条", width=width, height=height, cornerRadius=999,
fill=solid("$c-accent"))
def divider():
return rect(ids, "分隔线", width="fill_container", height=2,
fill=solid("$c-border"))
def centered_body(name, child):
"""把内容块撑到页标题以下的整块空间里再垂直居中。
页标题每页固定在同一高度(重复)是 deck 读起来整齐的前提;正文块又不
该被拉长到失真。两个诉求靠这一层透明容器同时满足。
"""
return col(name, [child], gap=0, height="fill_container",
justifyContent="center")
def page_head(title, subtitle=None, *, size=76):
kids = [text(ids, "页标题", title, size, 700, "$c-ink", family=CJK,
line_height=1.12, spacing=-2)]
if subtitle:
kids.append(text(ids, "页副标题", subtitle, 30, 400, "$c-muted",
family=CJK, line_height=1.45))
return col("页头", kids, gap=18)
# ------------------------------------------------------------------ 01 封面
def cover():
lede = col("主张", [
text(ids, "主标题", "把三天的报价\n压进三分钟", 128, 700, "$c-ink",
family=CJK, line_height=1.08, spacing=-3),
accent_bar(),
text(ids, "副标题", "我们在做的,是让每一家工厂当场就能报出价来。",
30, 400, "$c-muted", family=CJK, line_height=1.45),
], gap=36)
meta = row("落款", [
text(ids, "演讲者", "启明智造 · 创始人 陈知远", 28, 500, "$c-ink",
family=CJK, width="fit_content", growth="auto"),
text(ids, "场合", "2026.08 · 种子轮路演", 28, 400, "$c-muted",
family=CJK, width="fit_content", growth="auto"),
], gap=32, align="center", justifyContent="space_between")
s = slide("01 封面", gap=48, justify="space_between",
decor=[glow(1500, 980, -420), glow(760, 1360, 620)])
s["content"]["children"] = [eyebrow("种子轮 · 融资路演"), lede, meta]
return s
# ------------------------------------------------------------------ 02 问题
PAINS = [
("算不清", "一份报价要问遍四个部门,等两天才知道能不能接。"),
("报不准", "凭经验拍的价,做完才发现毛利被工时和损耗吃光了。"),
("跟不上", "客户当场要数,我们只能说「回去核一下」——单子就这么走了。"),
]
def problem():
rows = []
for index, (title, desc) in enumerate(PAINS, 1):
if rows:
rows.append(divider())
rows.append(row(f"痛点 {index}", [
text(ids, "痛点序号", f"0{index}", 72, 700, "$c-accent",
family=NUM, width="fit_content", growth="auto",
line_height=1.0, spacing=-2),
col("痛点文案", [
text(ids, "痛点标题", title, 44, 600, "$c-ink", family=CJK,
line_height=1.2),
text(ids, "痛点说明", desc, 28, 400, "$c-muted", family=CJK,
line_height=1.5),
], gap=12),
], gap=48, align="start"))
s = slide("02 问题", gap=56, decor=[ghost_number("02", 1240, 470),
glow(900, -300, 520)])
s["content"]["children"] = [
page_head("报价这一步,卡住了整条链路",
"三个问题,每一个都在直接吃掉订单。"),
col("痛点列表", rows, gap=0, height="fill_container",
justifyContent="space_between"),
]
return s
# ------------------------------------------------------------------ 03 方案
SELLING_POINTS = [
("三分钟出价", "上传图纸,直接算出工时与材料。"),
("毛利先看见", "价格旁就是毛利率,接不接一眼定。"),
("越报越准", "每完成一单回流成本,模型自己修。"),
]
def solution():
cards = []
for title, desc in SELLING_POINTS:
cards.append(col("卖点卡", [
neon_rule(),
text(ids, "卖点标题", title, 40, 600, "$c-ink", family=CJK,
line_height=1.2),
text(ids, "卖点说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=24, height="fill_container", padding=[48, 40],
cornerRadius=20, fill=solid("$c-surface")))
s = slide("03 方案", gap=56, decor=[ghost_number("03", 1330, 20),
glow(1000, -260, -280)])
s["content"]["children"] = [
page_head("把报价变成一次点击",
"工艺库 + 实时成本模型,客户还在电话里,价就出来了。"),
centered_body("卖点区", row("卖点网格", cards, gap=36,
align="stretch")),
]
return s
# ------------------------------------------------------------------ 04 数据
METRICS = [
("42", "", "报价提速", "两天的活儿,三分钟出结果"),
("97", "%", "报价准确率", "与实际成本偏差不超过 3%"),
("2.3", "亿元", "在跑的订单金额", "来自 46 家制造企业"),
]
# 大数字 lineHeight 1.0 时基线离行盒底部约 0.2em;单位字号小得多,`end`
# 对齐会把它压到基线以下。这个补偿把单位的行盒底抬回大数字的基线附近,
# 让「42」和「倍」看起来站在同一条线上。jian 没有真正的 baseline 对齐
# AlignItems::from_css 把 "baseline" 折成 End所以只能这样补。
VALUE_SIZE = 148
UNIT_SIZE = 46
UNIT_BASELINE_LIFT = round((VALUE_SIZE - UNIT_SIZE) * 0.2)
def unit_family(unit):
"""汉字单位(亿元)排中文族,符号单位(× %)排 Inter 跟数字同源。"""
return CJK if any(ord(ch) > 0x2FFF for ch in unit) else NUM
def metric_card(value, unit, label, note):
unit_box = frame(ids, "单位", width="fit_content", height="fit_content",
layout="vertical", gap=0,
padding=[0, 0, UNIT_BASELINE_LIFT, 0], fill=[])
unit_box["children"] = [
text(ids, "单位文字", unit, UNIT_SIZE, 600, "$c-accent",
family=unit_family(unit), width="fit_content", growth="auto",
line_height=1.0),
]
return col("数据卡", [
neon_rule(),
row("数值", [
text(ids, "数值文字", value, VALUE_SIZE, 700, "$c-accent",
family=NUM, width="fit_content", growth="auto",
line_height=1.0, spacing=-4),
unit_box,
], gap=10, align="end", width="fit_content"),
text(ids, "指标名", label, 32, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "指标说明", note, 26, 400, "$c-muted", family=CJK,
line_height=1.45),
], gap=20, height="fill_container", justifyContent="center",
padding=[52, 44], cornerRadius=24, fill=solid("$c-surface"))
def metrics():
s = slide("04 数据", gap=56, decor=[ghost_number("04", 1330, 20),
glow(1100, 1200, -340)])
s["content"]["children"] = [
page_head("半年里,它已经在跑",
"不是路演里的假设,是 46 家工厂每天在用的数。"),
centered_body("数据区", row("数据网格",
[metric_card(*entry) for entry in METRICS],
gap=36, align="stretch")),
]
return s
# --------------------------------------------------------------- 05 里程碑
MILESTONES = [
("2026 Q3", "工艺库上线", "覆盖钣金、机加两条主线", True),
("2026 Q4", "接入 ERP", "报价直接落成订单", True),
("2027 Q1", "开放 API", "让客户在自己系统里报价", False),
("2027 Q2", "行业模板", "从制造扩到工程与装修", False),
]
# 时间轴列之间不留 gap轴线是每列自己那一段拼起来的列间一旦有间隙
# 轴就断成四截。列内文案靠右 padding 让出呼吸,轴线段照样贯穿。
TIMELINE_TEXT_INSET = 56
DOT = 28
def milestone_column(date, title, desc, reached):
"""一段时间轴。圆点与文案都左对齐,轴线从圆点向右延伸到下一个圆点。"""
if reached:
dot = rect(ids, "节点圆", width=DOT, height=DOT, cornerRadius=DOT // 2,
fill=solid("$c-accent"))
else:
dot = rect(ids, "节点圆", width=DOT, height=DOT, cornerRadius=DOT // 2,
fill=solid("$c-bg"), stroke=stroke("$c-accent", 4))
axis = row("轴段", [
dot,
rect(ids, "轴线", width="fill_container", height=4,
fill=solid("$c-border")),
], gap=0, align="center", height="fit_content")
# 上下两块文案带右内缩,轴段一律不写 padding —— 三个同类兄弟里只有两个
# 可归一化的 padding 值mixed-sibling-padding 因此不会误判(同一个值
# 出现两次,另一个不参与统计)。
head = col("节点日期", [
text(ids, "日期", date, 28, 600, "$c-accent", family=NUM,
line_height=1.2, spacing=0.5),
], gap=0, padding=[0, TIMELINE_TEXT_INSET, 0, 0])
body = col("节点文案", [
text(ids, "节点标题", title, 36, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "节点说明", desc, 26, 400, "$c-muted", family=CJK,
line_height=1.5),
], gap=10, padding=[0, TIMELINE_TEXT_INSET, 0, 0])
return col("里程碑", [head, axis, body], gap=28)
def roadmap():
s = slide("05 里程碑", gap=56, decor=[ghost_number("05", 1330, 20),
glow(880, -280, 560)])
s["content"]["children"] = [
page_head("接下来十二个月",
"前两步已经走完,这轮钱花在后两步上。"),
centered_body("时间轴区",
row("时间轴",
[milestone_column(*entry) for entry in MILESTONES],
gap=0, align="start")),
]
return s
# ---------------------------------------------------------------- 06 结尾
def closing():
lede = col("结语", [
accent_bar(),
text(ids, "结语标题", "如果你也觉得\n这事早该被解决", 100, 700,
"$c-ink", family=CJK, line_height=1.1, spacing=-3),
text(ids, "结语副标题", "我们缺的不是订单,是把它做快的那笔钱。",
30, 400, "$c-muted", family=CJK, line_height=1.45),
], gap=36)
# 名字与两行联络方式分成两层,不是排版洁癖:三个平级文本一旦字号 32 /
# 30 / 30sibling-inconsistency 会把那个 32 当成手滑。分层之后 32 是
# 标题、30 是并列的两行,各自内部一致。
contact = col("联系方式", [
text(ids, "联系人", "陈知远 · 创始人", 32, 600, "$c-ink", family=CJK,
line_height=1.3),
col("联络方式", [
text(ids, "邮箱", "zhiyuan@qimingzz.com", 30, 500, "$c-accent",
family=NUM, line_height=1.4),
text(ids, "电话", "+86 138 0000 0000", 30, 400, "$c-muted",
family=NUM, line_height=1.4),
], gap=10, width="fit_content"),
], gap=18, width="fit_content")
# 二维码占位用 frame 而不是 rectangleframe 会递归渲染子节点,里面那行
# 提示才显示得出来frame 同时是合法的图片拖放目标,把二维码图片拖上去
# 就直接替换掉这个占位。
qr = frame(ids, "二维码占位", width=200, height=200, layout="vertical",
gap=0, alignItems="center", justifyContent="center",
cornerRadius=16, fill=solid("$c-accent-soft"),
stroke=stroke("$c-accent", 2))
qr["children"] = [
text(ids, "二维码提示", "扫码看 Demo", 26, 600, "$c-accent",
family=CJK, width="fit_content", growth="auto",
line_height=1.3),
]
card = row("联系卡", [contact, qr], gap=48, align="center",
justifyContent="space_between", padding=[48, 56],
cornerRadius=24, fill=solid("$c-surface"))
s = slide("06 结尾", gap=56, justify="space_between",
decor=[glow(1600, -520, -460), glow(820, 1300, 540)])
s["content"]["children"] = [lede, card]
return s
# 顶层 frame 必须显式写 x/y缺省时每帧都落在原点六页会叠成一页另外
# 五页藏在下面2026-08-02 实录)。横向 gap 与纵向 gap 的取值同 tpl_slides
# 纵向多留 240是给画布上固定在帧顶部的帧名标签让位——标签是屏幕空间的
# 不随缩放变小deck 缩到能整屏看时 120 文档像素只剩十几个屏幕像素。
BOARD_GAP_X = 120
BOARD_GAP_Y = BOARD_GAP_X + 240
BOARDS_PER_ROW = 3
def build():
boards = [cover(), problem(), solution(), metrics(), roadmap(), closing()]
for index, board in enumerate(boards):
board.pop("content", None)
board["x"] = (index % BOARDS_PER_ROW) * (W + BOARD_GAP_X)
board["y"] = (index // BOARDS_PER_ROW) * (H + BOARD_GAP_Y)
return boards
# 对比度WCAG 相对亮度比,投影场景自设门槛 4.5,远高于 op-design-lint 的
# 2.5)。页面底是渐变,所以按**三个 stop 分别量、取最差**——lint 只读
# stops[0],靠它兜不住:
# c-ink on 页面渐变 17.30 / 15.87 / 16.72
# c-muted on 页面渐变 7.44 / 6.82 / 7.19
# c-accent on 页面渐变 5.86 / 5.38 / 5.67
# c-ink on c-surface 13.98 c-muted on c-surface 6.01
# c-accent on c-surface 4.73 ← 最低一对 c-accent on c-accent-soft 4.90
# 数据页的 148px 大数字用的是 c-accent on c-surface = 4.73:1。换强调色时
# 先拿这一对量一遍:深底上亮色越纯,比值掉得越快。
# 光晕与 ghost 页码不参与:前者是纯装饰兄弟,后者是「满色 + 7% 图层透明
# 度」,两者都不承担阅读。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "路演 deck · 深色 16:9")