openpencil/templates/step0/_generators/deck_sounding_navy.py
Fini 728a32e7f6 feat(editor): add six 16:9 deck templates
The scene-template library carried one deck against a dozen carousel and
tutorial boards, so anyone starting a presentation started from a blank
board. These six cover the archetypes that actually get presented:
consulting strategy, data review, editorial keynote, workshop notes,
case dossier and financial ledger.

Each is seven or eight boards built from a shared generator kit, so the
grid, type scale and board wrap stay one decision rather than six.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-09 19:53:37 +08:00

700 lines
33 KiB
Python
Raw 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
"""sounding-navy-deck.op — 测深海图 · 咨询策略档D77 页 1920×1080
落格high formality × medium density × **mixed**(深浅书挡)。
定位:把判断当水深报出来的策略交付档 —— 每页一个结论式标题、一条可追溯的
证据、一行带来源的读数。
### 锚点
航海测深海图。海图纸的暖白、等深线的钢蓝、深水区的墨蓝、浅滩警戒的赭黄。
锚的是**「图上每一个数字都是有人下去量过的」**这件事 —— 这正是策略档要传达
的:结论不是观点,是读数。不锚船、锚、罗盘、舵轮等任何具象符号。
### 三个招牌母题
1. **深度剖面条**04 页)—— 条不浮空,从顶部一条 2px 基线**向下垂**,读作
水深。与业界通行的浮动瀑布图的区别是刻意的:浮动条读作「位置」,下垂条
读作「深度」,后者与「损失/消耗」的语义同向,也让基线成为唯一的对齐锚。
2. **航迹目录条**02 页展开 / 0306 页页脚缩略)—— 一条 2px 航迹线上等距
排 5 个圆点,当前节实心。展开态给全局,缩略态是跨页的进度锚。
3. **读数条**03 页)—— 页脚上方一条 3px 钢蓝上边线,线下左侧 mono 数值 +
右侧一句结论。**每页至多一条**,它就是这一页的 takeaway 落点。放页脚不放
要点框,是因为要点框会和正文争视觉权重,而页脚位置天然是「读完之后」。
### 负约束spec §2 D7 Strictly avoid逐条落实
- 零圆角。圆角一出现本档立刻从「海图」滑向「SaaS 落地页」。
- 零阴影、零渐变、零玻璃拟态 —— 唯一的「深度」是剖面条表达的语义深度。
- 内容不装卡片。分区靠 1px 线与留白;`chart.paper.deep` 面整套只出现在
06 页右栏05 页表格改用真格线,连斑马都不用)。
- **`shoal.ochre` 整套只出现一次**06 页右栏顶部那条 96×4 短线)。警戒色
出现两次就不再是警戒。
- 标题只写结论式完整句,不写话题式;标题下不画横线。
- 图无图例、无网格线、无 y 轴;数值直接标在图形上;非 key 系列走中性灰。
- 深页只在开头两页与结尾一页成对出现(书挡),中间内容页一律亮底。
- 不出现按钮态 / tab / pill / badge / 导航条等任何暗示可交互的元素。
- 出现数字的页面必须有来源行。
- 钢蓝与赭黄之外不用第三支有彩色。
### 密度核对§3.2,槽位按「一条要点计 1」
01 封面 3/4 · 02 航迹 6/6 · 03 结论 6/6 · 04 证据 5/6
05 数据 9/10 · 06 取舍 6/6 · 07 行动 5/5
页码与页脚眉标是母版家具§4.1 边距豁免名单里的 footer/page 角色),不占槽位。
对比度实测见文件末尾。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from deckkit import (CJK, H, NUM, W, assert_type_scale, audit_document,
cjk_width, latin_track, lh, place_boards, track)
from oplib import Ids, color_vars, frame, rect, solid, text, write_doc
ids = Ids()
# 色板全部来自 spec §2 D7 的色板表oklch 实算,非估值)。
#
# 两条推导必须留在这里,否则将来「顺手统一一下色相」会把这一档的身份抹掉:
# · 深底不用纯黑,用 chroma 0.048 的墨蓝。纯黑封面配白字是所有 AI deck 的
# 默认出厂设置;带 chroma 的深底让钢蓝在其上读作「同一族颜色的深处」。
# · 亮底的 chroma 走 H85 暖向、深底走 H250 冷向。海图纸是暖的、海水是冷的,
# 这个冷暖分裂是「纸 vs 水」的物理关系,让 mixed 的深浅两态有真实材质差
# 别,而不是同一个色的明暗两级。
VARS = color_vars({
"c-paper": "#F7F4EE", # 内容页底
"c-paper-deep": "#EBE7DF", # 分区面(整套只用于 06 页右栏)
"c-navy": "#091E32", # 封面 / 分节 / 收尾页底
"c-navy-low": "#031223", # 深页上的更深块
"c-ink": "#1D2228", # 亮页主文字 14.58 : paper
"c-ink-soft": "#565B61", # 亮页次级文字 6.24
"c-ink-faint": "#7C8186", # 脚注 / 页码 3.58(仅 ≥24px
"c-on-navy": "#F0F4F7", # 深页主文字 15.27 : navy
"c-on-navy-dim": "#B3B8BE", # 深页次级文字 8.46
"c-steel": "#266EA4", # 主 accent 4.97 : paper
"c-steel-deep": "#044A7D", # 承载白字的钢蓝 8.39(白字)
"c-steel-lift": "#83B0D7", # 深页上的 accent 7.36 : navy
"c-ochre": "#A46D1A", # 第二色·警戒,整套一次 4.00
"c-rule": "#D3D1CD", # 栏线 / 表格线(非文字)
})
# §2 D7 版式语法:下边距 > 上边距。投影下沿常被前排人头遮挡 —— 这是真实
# 约束不是趣味,同时它顺手打破了「万物居中且四边等距」这条 AI 指纹。
PAD_TOP, PAD_BOTTOM, PAD_X = 96, 120, 120
INNER = W - PAD_X * 2 # 1680
GUTTER = 24 # gap 基准,本档所有间距都是它的整数倍
# 线宽只有三档。多一档就要解释「为什么这里 2.5」,而海图上没有 2.5。
HAIR, RULE, HEAVY = 1, 2, 3
# 字号§2 D7。全部过 assert_type_scale 的地板自检。
FS_COVER = 104 # 封面主标(区间 100116
FS_TITLE = 64 # 结论式页标题
FS_CLOSE = 72 # 收尾页收束句
FS_LEDE = 36 # 引导句
FS_ITEM = 40 # 论据标题 / 剖面注解标题
FS_BODY = 32 # 正文
FS_TABLE = 30 # 表格正文
FS_NOTE = 24 # 脚注 / 来源 / 页码(本档唯一允许 24 的位置)
FS_READOUT = 48 # 读数条的 mono 数值
# 本档零圆角。写成常量是为了让「有没有人偷偷加了圆角」在 diff 里可数。
RADIUS = 0
# 五个测点与 0307 五张内容页一一对应:航迹缩略的「当前节」因此是
# 页码的另一种写法,不是另一套编号。
SECTIONS = ["总体结论", "毛利去向", "安装网络", "取舍判断", "行动清单"]
# ------------------------------------------------------------------ 骨架
def slide(name, *, dark=False, gap=GUTTER * 2, justify="start"):
"""一帧。外壳 layout:none + 内层 flex与本仓既有 deck 同构。
外壳退成 layout:none 的理由:正文层是一个 flex 容器,任何背景装饰挂进去
都会变成参与排版的兄弟把内容挤走。本档虽然装饰极少(只有几条线),但结构
保持一致,将来补装饰时不必回头改。
"""
body = frame(ids, f"{name} · 正文", width="fill_container",
height="fill_container", layout="vertical",
padding=[PAD_TOP, PAD_X, PAD_BOTTOM, PAD_X], gap=gap,
justifyContent=justify, alignItems="start", fill=[])
body["children"] = []
shell = frame(ids, name, width=W, height=H, layout="none",
fill=solid("$c-navy" if dark else "$c-paper"),
clipContent=True)
shell["children"] = [body]
shell["content"] = body
return shell
def col(name, children, *, gap=GUTTER, width="fill_container",
height="fit_content", align="start", fill=None, **props):
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=GUTTER, width="fill_container",
height="fit_content", align="start", 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 hrule(color="$c-rule", thickness=HAIR, width="fill_container"):
"""横线一律用 rectangle。
§6.3`line` 在 layout:none 容器下吃的是文档绝对坐标而不是父容器局部
坐标,会让线出现在画布的另一个角落。本档所有线(航迹线、表格线、读数条
上边线、中缝)都是 rect不要「优化」成 line。
"""
return rect(ids, "横线", width=width, height=thickness, fill=solid(color))
def vrule(height, color="$c-rule", thickness=HAIR):
return rect(ids, "竖线", width=thickness, height=height,
fill=solid(color))
def title(content, *, dark=False):
"""结论式页标题。≤2 行,断点在文案里显式写 \\n。
§3.5:允许 ≤2 行的标题必须显式指定断点,不交给自动折行 —— 当前渲染栈对
CJK 是逐字符断行、没有标点避头尾,断在「,」前面是可能的。
"""
return text(ids, "页标题", content, FS_TITLE, 700,
"$c-on-navy" if dark else "$c-ink", family=CJK,
line_height=lh(FS_TITLE), spacing=track(FS_TITLE),
width=INNER)
def note(content, *, dark=False, width=None):
return text(ids, "来源脚注", content, FS_NOTE, 400,
"$c-on-navy-dim" if dark else "$c-ink-faint", family=CJK,
line_height=lh(FS_NOTE), width=width or cjk_width(FS_NOTE,
INNER))
def sounding_dot(ring, size, *, filled):
"""航迹点。实心 = 当前节,空心 = 其余节。
空心态靠 1.5px 描边而不是「填一个底色接近页底的圆」:换页底时描边自己
还在,填色版会在深浅两态之间各错一次。
"""
props = {"width": size, "height": size, "cornerRadius": size // 2}
if filled:
props["fill"] = solid(ring)
else:
props["fill"] = []
props["stroke"] = {"thickness": 1.5, "fill": solid(ring)}
return rect(ids, "航迹点", **props)
def track_dots(active, *, width=INNER, dark=True, dot=12):
"""航迹目录条的**缩略态**:一条线 + 5 个点,当前节实心。
放在内容页页脚,是这个母题作为「跨页进度锚」的落点。缩略态不带节名 ——
节名在 02 页给过一次,页脚再写一遍就成了每页重复的文字噪声。
叠放顺序§6.4`layout:none` 的 children[0] 在**最上**(与业界惯例相
反)。所以圆点行写在数组第一个,航迹线写在后面。
"""
ring = "$c-steel-lift" if dark else "$c-steel"
base = "$c-on-navy-dim" if dark else "$c-rule"
lane = (width - dot) / (len(SECTIONS) - 1)
dots = []
for index in range(len(SECTIONS)):
node = sounding_dot(ring, dot, filled=index == active)
node["x"] = round(index * lane, 2)
node["y"] = 0
dots.append(node)
dot_layer = frame(ids, "航迹点层", width=width, height=dot,
layout="none", fill=[])
dot_layer["children"] = dots
line = rect(ids, "航迹线", width=width, height=RULE, fill=solid(base))
line["x"], line["y"] = 0, (dot - RULE) / 2
shell = frame(ids, "航迹条", width=width, height=dot, layout="none",
fill=[])
shell["children"] = [dot_layer, line]
return shell
def footer(page, *, dark=False, active=None, source=None):
"""页脚家具:眉标 + 航迹缩略 + 页码。
§3.3 把「页码与页标位置」列进**不允许跨页变**的一栏,所以这一条在 0207
页的 y 上完全相同01 封面不带页脚(封面是陈述不是信息)。
航迹缩略放在这里,是「航迹目录条」这个母题作为**跨页进度锚**的落点:
02 页给一次全展开,之后每一页在同一个位置复现一次缩略态。观众任何时候
抬头都能看出现在讲到第几个测点,而不必记住议程。
"""
faint = "$c-on-navy-dim" if dark else "$c-ink-faint"
label = "明澜 · 渠道诊断"
if active is not None:
label += f"{SECTIONS[active]}"
left = text(ids, "页脚眉标", label, FS_NOTE, 400, faint, family=CJK,
width="fit_content", growth="auto", line_height=lh(FS_NOTE))
folio = text(ids, "页码", f"{page:02d} / 07", FS_NOTE, 500, faint,
family=NUM, width="fit_content", growth="auto",
line_height=lh(FS_NOTE), spacing=latin_track("07"))
kids = [left]
if active is not None:
kids.append(track_dots(active, width=200, dark=dark, dot=8))
kids.append(folio)
bar = row("页脚", kids, gap=GUTTER, align="center",
justifyContent="space_between")
if source is None:
return bar
return col("页脚区", [note(source, dark=dark), bar], gap=GUTTER)
# ------------------------------------------------------------------ 01 封面
def cover():
code = text(ids, "项目代号", "MLN · 26H1 渠道诊断", FS_NOTE, 500,
"$c-steel-lift", family=CJK, width="fit_content",
growth="auto", line_height=lh(FS_NOTE))
lede = col("主张", [
text(ids, "主标题", "换渠道救不回毛利\n漏水口在安装这一段",
FS_COVER, 700, "$c-on-navy", family=CJK,
line_height=lh(FS_COVER), spacing=track(FS_COVER), width=INNER),
rect(ids, "短线", width=120, height=HEAVY,
fill=solid("$c-steel-lift")),
], gap=GUTTER * 2)
meta = row("落款", [
text(ids, "委托方", "明澜家用水处理 · 战略办公室", 26, 400,
"$c-on-navy-dim", family=CJK, width="fit_content", growth="auto",
line_height=lh(26)),
text(ids, "日期", "2026.08", 26, 500, "$c-on-navy-dim", family=NUM,
width="fit_content", growth="auto", line_height=lh(26),
spacing=latin_track("2026.08")),
], gap=GUTTER, align="center", justifyContent="space_between")
s = slide("01 封面", dark=True, gap=GUTTER * 2, justify="space_between")
s["content"]["children"] = [code, lede, meta]
return s
# -------------------------------------------------------------- 02 议题航迹
def agenda():
"""分节总览:航迹目录条**全展开**,五个节点全部带中文节名,无正文。
五个节名各占一列、列宽均分。列与列之间留 GUTTER 的真间隙而不是靠居中
对齐挤出来 —— §6.2:相邻同族元素贴到 0px 会被几何检测器判成「读起来连成
一个词」的 jam也确实读不出分栏。
"""
# 圆点与节名走**同一套等分列**:五个 fill_container 列 + 同样的沟槽,各自
# 在列内居中。早先的写法把点按 (INNER dot)/4 的绝对步长摆、名字交给等分
# 列,两套算法的中心差到 150px —— 目录条上点与名对不上,整个母题就废了。
dot = 20
dot_cells = []
for index in range(len(SECTIONS)):
cell = frame(ids, "航迹点位", width="fill_container", height=dot,
layout="horizontal", gap=0, alignItems="center",
justifyContent="center", fill=[])
cell["children"] = [sounding_dot("$c-steel-lift", dot,
filled=index == 0)]
dot_cells.append(cell)
dot_row = row("航迹点行", dot_cells, gap=GUTTER, align="center")
names = []
for index, label in enumerate(SECTIONS):
names.append(text(ids, "节名", label, FS_ITEM, 600,
"$c-on-navy" if index == 0 else "$c-on-navy-dim",
family=CJK, line_height=lh(FS_ITEM),
align="center"))
name_row = row("节名行", names, gap=GUTTER, align="start")
lane = frame(ids, "航迹线", width=INNER, height=RULE,
fill=solid("$c-on-navy-dim"))
board = col("航迹目录", [lane, dot_row, name_row], gap=GUTTER)
s = slide("02 议题航迹", dark=True, gap=GUTTER * 2,
justify="space_between")
s["content"]["children"] = [
title("这次诊断,我们只下了五个测点", dark=True),
board,
footer(2, dark=True),
]
return s
# -------------------------------------------------------------- 03 结论页
# 说明句一律压到一行≤30 汉字)。这是 §3.1 铁律一的正面执行:装不下时
# 第一个动作是**砍文案**,不是缩字号、不是把行距压掉、更不是加 clipContent。
# 被砍掉的对照数字挪进了 05 页那张表 —— 它本来就该在表里被逐行核对。
ARGUMENTS = [
("上门当天装完的门店,复购没掉过",
"华东 91% 的工单当天闭环,华南 88%"),
("被改约一次,客户基本就走了",
"改约过的工单,年内复购只剩 9%"),
("装不好不是缺人,是缺件",
"西南 47% 的改约理由是配件不在车上。"),
]
def readout(value, conclusion):
"""读数条。3px 钢蓝上边线,线下左 mono 数值、右一句结论。
数值走 `c-steel-deep` 而不是 accent 本色:一页只允许 accent 出现一次,
那一次给了这条上边线 —— 它是把「这一页的读数」从正文里分出来的那个动作。
数值本身跟着走深一档,读作墨重而不是第二次强调。
"""
line = rect(ids, "读数上边线", width="fill_container", height=HEAVY,
fill=solid("$c-steel"))
body = row("读数", [
text(ids, "读数数值", value, FS_READOUT, 700, "$c-steel-deep",
family=NUM, width="fit_content", growth="auto",
line_height=1.05, spacing=latin_track(value)),
text(ids, "读数结论", conclusion, FS_BODY, 500, "$c-ink", family=CJK,
width=cjk_width(FS_BODY, 1160), align="right"),
], gap=GUTTER * 2, align="center", justifyContent="space_between")
return col("读数条", [line, body], gap=GUTTER)
def conclusion():
items = []
for index, (head, desc) in enumerate(ARGUMENTS, 1):
items.append(row(f"论据 {index}", [
text(ids, "论据序号", f"{index:02d}", FS_ITEM, 500,
"$c-ink-faint", family=NUM, width=72, growth="fixed-width",
line_height=lh(FS_ITEM), spacing=latin_track("01")),
col("论据文案", [
text(ids, "论据标题", head, FS_ITEM, 600, "$c-ink",
family=CJK, line_height=lh(FS_ITEM),
width=cjk_width(FS_ITEM, INNER - 96)),
text(ids, "论据说明", desc, FS_BODY, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_BODY, body=True),
width=cjk_width(FS_BODY, INNER - 96)),
], gap=8),
], gap=GUTTER, align="start"))
s = slide("03 结论", gap=GUTTER * 2, justify="space_between")
s["content"]["children"] = [
col("论证", [
title("复购只发生在\n上门当天能装完的门店"),
text(ids, "引导句", "同一款机器、同一个价,差别不在渠道,在人。",
FS_LEDE, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_LEDE, body=True),
width=cjk_width(FS_LEDE, INNER)),
col("论据列表", items, gap=GUTTER),
], gap=GUTTER * 2),
col("页尾", [
readout("3.1×", "复购差距来自安装,不来自价格。"),
footer(3, active=0),
], gap=GUTTER + 8),
]
return s
# -------------------------------------------------------------- 04 证据页
# (名称, 数值, 相对水深, 是否 key)。首尾总量实心钢蓝深,中间减项走中性灰,
# 唯一的 key 项走 accent —— 一页一次强调,那一次要落在页面真正的论点上。
#
# 减项不用 `c-ochre`:赭黄整套只出现一次,那一次给了 06 页的取舍代价。
# §2 D7 的母题描述里写的是「减项用 shoal.ochre」与页型清单里「06 页是唯一
# 一次」冲突;页型清单是结构契约、母题描述是表现建议,取前者。中性灰同样满足
# 「非 key 系列走中性阶」这条更硬的规则。
PROFILE = [
("出厂毛利", "42.0", 420, "anchor"),
("渠道返点", "6.4", 64, "plain"),
("仓配摊销", "3.1", 31, "plain"),
("安装返工", "11.8", 118, "key"),
("退换保修", "2.7", 27, "plain"),
("到手毛利", "18.0", 180, "anchor"),
]
PROFILE_COLORS = {"anchor": "$c-steel-deep", "key": "$c-steel",
"plain": "$c-ink-faint"}
CHART_W = 1152
ANNOTATE_W = INNER - CHART_W - GUTTER * 2 # 480
def depth_profile():
"""深度剖面条。
条**不浮空**:全部从顶部那条 2px 基线向下垂,读作水深。这与业界通行的
浮动瀑布图不同,而且是刻意的 —— 浮动条读作「位置」,下垂条读作「深度」,
后者与「损失 / 消耗」的语义同向,也让基线成为整张图唯一的对齐锚。
数值紧贴每条的下端而不是排在图外的一行§3.6 图表去装饰要求数值直接标在
图形上;而「每个数字停在自己那一档水深上」正好就是测深图的读法。
"""
bar_w = (CHART_W - GUTTER * (len(PROFILE) - 1)) // len(PROFILE)
columns = []
for label, value, depth, kind in PROFILE:
columns.append(col(f"{label}", [
rect(ids, "剖面条", width=bar_w, height=depth,
fill=solid(PROFILE_COLORS[kind])),
text(ids, "剖面数值", value, 28, 600,
"$c-ink" if kind != "plain" else "$c-ink-soft", family=NUM,
line_height=1.2, align="center",
spacing=latin_track(value)),
text(ids, "剖面标签", label, FS_NOTE, 400, "$c-ink-faint",
family=CJK, line_height=lh(FS_NOTE), align="center"),
], gap=8, width=bar_w, align="start"))
bars = row("剖面列", columns, gap=GUTTER, width=CHART_W, align="start")
baseline = rect(ids, "剖面基线", width=CHART_W, height=RULE,
fill=solid("$c-ink"))
content = col("剖面内容", [baseline, bars], gap=0, width="fill_container",
height="fill_container")
# 这里曾经画过一条「到手毛利水位」的虚线横贯全图。删掉了:每条的数值与
# 名称停在自己那一档水深上,任何一条水平参考线都必然从某一组标签中间穿
# 过去,读起来像给文字打了删除线。**一条划过文字的参考线比没有参考线更
# 糟** —— 水位这件事已经由「到手毛利」那根实心条自己说清楚了。
chart_h = RULE + max(p[2] for p in PROFILE) + 8 + 34 + 8 + 32
shell = frame(ids, "深度剖面条", width=CHART_W, height=chart_h,
layout="none", fill=[])
shell["children"] = [content]
return shell
# 每一条说明的断点都写死在文案里§3.5 / §6.11)。当前渲染栈对 CJK 是逐字符
# 断行、**没有标点避头尾**,交给自动折行就会出现「。」独占一行这种事故;而
# 注解栏只有 480 宽、30px 一行放得下 16 字,正是最容易踩的那一档。
ANNOTATIONS = [
("返工的钱不在财务账上",
"它记在售后工时里,\n季度报表看不见,\n年底盘账才发现。"),
("换渠道省不下这一段",
"经销商换一轮,\n安装还是那批人。\n返点让掉两个点,返工照旧。"),
]
def evidence():
blocks = []
for index, (head, desc) in enumerate(ANNOTATIONS):
if blocks:
blocks.append(hrule())
blocks.append(col("注解组", [
text(ids, "注解标题", head, FS_LEDE, 600, "$c-ink", family=CJK,
line_height=lh(FS_LEDE), width=ANNOTATE_W),
text(ids, "注解说明", desc, FS_TABLE, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_TABLE, body=True),
width=ANNOTATE_W),
], gap=8))
band = row("证据区", [
depth_profile(),
col("注解栏", blocks, gap=GUTTER + 8, width=ANNOTATE_W),
], gap=GUTTER * 2, align="start")
s = slide("04 证据", gap=GUTTER * 2, justify="space_between")
s["content"]["children"] = [
col("论证", [title("毛利不是被渠道拿走的\n是被返工吃掉的"), band],
gap=GUTTER * 2),
footer(4, active=1,
source="数据来源:明澜 2026 年 16 月工单与售后台账,单位 元/台,"
"样本 626 家门店。"),
]
return s
# -------------------------------------------------------------- 05 数据页
# 列宽写死并且必须加起来等于 INNER。表格没有自动列宽写错就溢出
# §6.10「渲染栈无 tabular 数字」意味着数值列也不能靠 fit_content 指望数字
# 自己对齐 —— 定宽 + 右对齐是今天唯一能让一列数字排齐的写法。
TABLE_COLS = [384, 240, 300, 300, 360]
assert sum(TABLE_COLS) + GUTTER * (len(TABLE_COLS) - 1) == INNER
TABLE_HEAD = ["区域", "门店数", "平均上门(天)", "一次装成率", "年内复购"]
TABLE_ROWS = [
("华东", "186", "2.4", "91%", "34%", False),
("华南", "142", "3.1", "88%", "29%", False),
("华北", "128", "4.6", "74%", "18%", False),
("华中", "96", "5.2", "69%", "15%", False),
("西南", "74", "6.8", "61%", "11%", False),
("合计", "626", "4.1", "78%", "22%", True),
]
def table_row(cells, *, size, weight, color, head=False):
kids = []
for index, (value, width) in enumerate(zip(cells, TABLE_COLS)):
first = index == 0
kids.append(text(ids, "表头单元" if head else "表格单元", value, size,
weight, color,
family=CJK if first or head else NUM,
line_height=lh(size), width=width,
align=None if first else "right",
spacing=0 if (first or head) else
latin_track(value)))
return row("表行", kids, gap=GUTTER, align="center")
def data_table():
parts = [
table_row(TABLE_HEAD, size=26, weight=600, color="$c-ink-soft",
head=True),
# 表头下边线是这一页唯一的 accent 出现。数值一律走墨色 —— 把一整列
# 数字染成强调色,是「用了一个强调色但它出现了十一次」那种失败。
hrule("$c-steel", RULE),
]
for index, (*cells, total) in enumerate(TABLE_ROWS):
parts.append(table_row(cells, size=FS_TABLE,
weight=600 if total else 400,
color="$c-ink" if total else "$c-ink-soft"))
# 末行无线。表格的最后一条线画出去,表就读作一个被框住的块而不是
# 一列还能往下续的记录。
if index < len(TABLE_ROWS) - 1:
parts.append(hrule())
return col("区域表", parts, gap=GUTTER - 6)
def data_page():
s = slide("05 数据", gap=GUTTER * 2, justify="space_between")
s["content"]["children"] = [
col("论证", [title("装得快的区域,复购就没有掉过"), data_table()],
gap=GUTTER * 2),
footer(5, active=2,
source="数据来源:明澜工单系统 2026 年 16 月,一次装成率指首次"
"上门即完成安装并通过验收的比例。"),
]
return s
# -------------------------------------------------------------- 06 取舍页
# 两栏 7:3 的不对称。§3.6:两栏页禁止 50/50 —— 50/50 稳定但静止,不对称
# 才产生视觉方向;这一页的方向是「证据在左、判断在右」。
LEFT_W = 1140
# 三个孩子(左栏 / 中缝 / 右栏)之间有**两条**沟槽,右栏宽必须把两条都扣
# 掉。少扣一条的后果不是「差一点」,是整行宽出 24px 被判成溢出。
RIGHT_W = INNER - LEFT_W - HAIR - GUTTER * 2 # 491 ≈ 7.0 : 3.0
TRADEOFFS = [
("自有师傅的一次装成率是 94%",
"外包队伍 68%\n同样的培训,差别在返修要不要自己扛。"),
("改约主要卡在配件不在车上",
"不是人手不够。\n配件调度这一段本来就在我们自己手里。"),
("经销商并不反对我们自己装",
"他们要的是卖机器那笔返点,\n安装对他们一直是成本中心。"),
]
def tradeoff():
left_items = []
for head, desc in TRADEOFFS:
if left_items:
left_items.append(hrule())
left_items.append(col("证据条", [
text(ids, "证据标题", head, FS_LEDE, 600, "$c-ink", family=CJK,
line_height=lh(FS_LEDE), width=cjk_width(FS_LEDE, LEFT_W)),
text(ids, "证据说明", desc, FS_TABLE, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_TABLE, body=True),
width=cjk_width(FS_TABLE, LEFT_W)),
], gap=8))
# 赭黄整套唯一的一次。给的是一条 96×4 的短线而不是一段染色文字:警戒色
# 压到 4.00 才换来可读性,把它花在文字上就必须再论证一次对比度;而一条
# 实心短线不承担阅读,反而更像海图上那道浅滩警戒。
right = col("判断栏", [
rect(ids, "警戒短线", width=96, height=4, fill=solid("$c-ochre")),
text(ids, "判断标题", "这一步的代价", FS_BODY, 600, "$c-ink",
family=CJK, line_height=lh(FS_BODY),
width=cjk_width(FS_BODY, RIGHT_W - 64)),
text(ids, "判断说明",
"自建安装要多背\n三年四千三百万固定成本。\n换来的是复购,\n不是当期毛利。",
FS_TABLE, 400, "$c-ink", family=CJK,
line_height=lh(FS_TABLE, body=True),
width=cjk_width(FS_TABLE, RIGHT_W - 64)),
], gap=GUTTER, width=RIGHT_W, height="fill_container",
padding=[32, 32], fill=solid("$c-paper-deep"))
band = row("取舍区", [
col("证据栏", left_items, gap=GUTTER, width=LEFT_W),
vrule("fill_container"),
right,
], gap=GUTTER, align="stretch")
s = slide("06 取舍", gap=GUTTER * 2, justify="space_between")
s["content"]["children"] = [
col("论证", [title("该换的不是经销商\n是把安装收回来自己做"), band],
gap=GUTTER * 2),
footer(6, active=3),
]
return s
# -------------------------------------------------------------- 07 行动页
ACTIONS = [
"九月底前接管\n华南两个仓的配件调度",
"十月起在广深\n自招一百二十名安装师傅",
"年底复盘一次装成率,\n到不了九成就停",
]
def action():
action_w = (INNER - GUTTER * 2 * 2) // 3
cards = []
for index, line in enumerate(ACTIONS, 1):
cards.append(col(f"行动 {index}", [
text(ids, "行动序号", f"{index:02d}", FS_ITEM, 500,
"$c-steel-lift", family=NUM, line_height=lh(FS_ITEM),
spacing=latin_track("01"), width=action_w),
text(ids, "行动文案", line, FS_BODY, 500, "$c-on-navy",
family=CJK, line_height=lh(FS_BODY, body=True),
width=cjk_width(FS_BODY, action_w)),
], gap=GUTTER - 8, width=action_w))
s = slide("07 行动", dark=True, gap=GUTTER * 2, justify="space_between")
s["content"]["children"] = [
text(ids, "收束句", "先把华南的安装收回来\n再谈全国", FS_CLOSE, 700,
"$c-on-navy", family=CJK, line_height=lh(FS_CLOSE),
spacing=track(FS_CLOSE), width=INNER),
row("行动区", cards, gap=GUTTER * 2, align="start"),
col("页尾", [
text(ids, "落款", "明澜家用水处理 · 战略办公室2026 年 8 月 12 日",
26, 400, "$c-on-navy-dim", family=CJK, line_height=lh(26),
width=cjk_width(26, INNER)),
footer(7, dark=True, active=4),
], gap=GUTTER),
]
return s
# 字号地板自检§3.4)。产稿时跑一遍比渲染完看检测器报告便宜得多。
assert_type_scale([FS_COVER, FS_TITLE, FS_CLOSE, FS_LEDE, FS_ITEM, FS_BODY,
FS_TABLE, FS_NOTE, FS_READOUT, 26, 28],
where="sounding-navy-deck")
def build():
boards = [cover(), agenda(), conclusion(), evidence(), data_page(),
tradeoff(), action()]
for board in boards:
board.pop("content", None)
place_boards(boards)
# 本档零圆角:`allowed_radii` 传空集,只有正圆(航迹点)能过。约束的是
# 圆角矩形容器 —— 圆点要圆就必须写 cornerRadius那不是同一件事。
return audit_document(boards, where="sounding-navy-deck",
allowed_radii=frozenset())
# 对比度WCAG 相对亮度比实算。deck 是投影物,自设门槛远高于
# op-design-lint 的 2.5
# c-ink on c-paper 14.58 c-ink-soft on c-paper 6.24
# c-ink-faint on c-paper 3.58 ← 只承载 ≥24px 的脚注/页码/图注
# c-ink on c-paper-deep 13.34 c-ink-soft on c-paper-deep 5.71
# c-on-navy on c-navy 15.27 c-on-navy-dim on c-navy 8.46
# c-steel-lift on c-navy 7.36 c-steel on c-paper 4.97
# c-steel-deep on c-paper 9.24 c-ochre on c-paper 4.00
# `c-ochre` 的 4.00 是刻意压出来的:初版取 L0.700 时对纸只有 2.47,一个读不清
# 的「警戒色」是自相矛盾。本档它只用在一条实心短线上,不承担阅读。
# `c-rule` 与剖面基线是**线不是字**§4.4 对比豁免第 2 条),不参与对比核算。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "测深海图 · 咨询策略档 16:9")