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
This commit is contained in:
Fini 2026-08-09 19:53:37 +08:00
parent 3a8c154739
commit 728a32e7f6
63 changed files with 42882 additions and 0 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,699 @@
#!/usr/bin/env python3
"""sounding-navy-deck.op — 测深海图 · 咨询策略档(D7,7 页 1920×1080)
落格:high formality × medium density × **mixed**(深浅书挡)。
定位:把判断当水深报出来的策略交付档 —— 每页一个结论式标题、一条可追溯的
证据、一行带来源的读数。
### 锚点
航海测深海图。海图纸的暖白、等深线的钢蓝、深水区的墨蓝、浅滩警戒的赭黄。
锚的是**「图上每一个数字都是有人下去量过的」**这件事 —— 这正是策略档要传达
的:结论不是观点,是读数。不锚船、锚、罗盘、舵轮等任何具象符号。
### 三个招牌母题
1. **深度剖面条**(04 页)—— 条不浮空,从顶部一条 2px 基线**向下垂**,读作
水深。与业界通行的浮动瀑布图的区别是刻意的:浮动条读作「位置」,下垂条
读作「深度」,后者与「损失/消耗」的语义同向,也让基线成为唯一的对齐锚。
2. **航迹目录条**(02 页展开 / 03–06 页页脚缩略)—— 一条 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 # 封面主标(区间 100–116)
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
# 五个测点与 03–07 五张内容页一一对应:航迹缩略的「当前节」因此是
# 页码的另一种写法,不是另一套编号。
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 把「页码与页标位置」列进**不允许跨页变**的一栏,所以这一条在 02–07
页的 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 年 1–6 月工单与售后台账,单位 元/台,"
"样本 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 年 1–6 月,一次装成率指首次"
"上门即完成安装并通过验收的比例。"),
]
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")

View file

@ -0,0 +1,689 @@
#!/usr/bin/env python3
"""tidemark-slate-deck.op — 潮痕石板 · 数据评审档(D8,7 页 1920×1080)
落格:medium-high formality × **high** density × light。
定位:把「这个周期发生了什么、相对上一个周期在哪」一次说清的评审档 ——
指标带刻度、状态有语义、路线走泳道。
### 锚点
港池的潮痕石。被反复涨落染出刻痕的石板灰、刻在石上的潮位标记、航道上的三色
浮标。锚的是**「刻痕就是上一次的位置」**这件事 —— 评审的本质不是报数,是报
「相对上次」。不锚海浪、船只、灯塔等任何具象物。
### 三个招牌母题
1. **潮位磁贴**(02 页)—— 四层:标签 → mono 数值 → **一条贯穿磁贴宽度的刻度
线,目标画成刻度上的一个位置** → 变化量 chip。目标不写成「目标:X」这行
字,而是画成一个位置 —— 这样「离目标多远」是被**看见**的,不是被读出来
的。这也是本档与通行 KPI 卡的分野所在。
2. **航标点**(04 / 05 页)—— 严格语义的三色圆点与药丸。硬规则:**任何带航标
点的行必须同时有「责任人」与「到期」两列**,缺任一项就把点删掉 —— 无主的
状态比没有状态更糟。
3. **潮汐泳道**(06 页)—— 左侧泳道名列 + 右侧时间区;条 = 淡蓝底 + 左端一段
实色;里程碑 = 旋转 45° 的方块。**里程碑与状态点必须形状可辨**,不能只靠
颜色区分(色觉障碍与投影衰减下颜色是最先失效的那一维)。
### 负约束(spec §2 D8 Strictly avoid,逐条落实)
- 航标三色**绝不作图表系列色**,只作点与药丸。图表永远单色阶。
- 图不用网格线、图例、y 轴刻度;数值直接标在柱端。
- 不用饼图 / 环形图 / 3D / 渐变柱 / 阴影柱。
- 磁贴之外零阴影(本稿更进一步:**整套零阴影** —— 磁贴与页底之间的明度差
已经够把它托起来,再压一层投影是替可读性买不需要的保险)。
- 有航标点的行必须有责任人与到期。
- 页标题写结论不写栏目名。
- 潮痕蓝之外只有中性与三支语义色,无第二支非语义有彩色。
- 数字一律 mono,且数值列宽写死(§6.10:渲染栈不保证 tabular 数字)。
- 不做双色柱对照;对照走灰色影子柱。
- 磁贴固定 4 张一行 —— 3 张留尴尬空档,5 张把 mono 64 逼到换行。
### 与 spec 的两处落地取舍
- **磁贴标签不加 letterSpacing。** spec 写「24px 大写标签 letterSpacing +2」,
那条是给大写拉丁写的;§3.8.2 同时规定「<48px 的中文一律 0」。本档文案全
中文,取后者 —— 中文加正字距会把词间关系拆散。
- **药丸高 36 / 圆角 18,不是 spec 写的 28 / 14。** 24px 中文的行盒就有 34px
高,28 的药丸会把字压到贴边。字号地板(§3.4)比装置尺寸硬,让装置。
### 密度核对(§3.2,槽位按「一条要点计 1」)
01 封面 5/5 · 02 全景磁贴 14/16 · 03 趋势 5/8 · 04 明细表 10/14
05 风险 5/9 · 06 泳道 9/10 · 07 决议 5/6
对比度实测见文件末尾。
"""
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, dash_col, latin_track, lh, place_boards,
track)
from oplib import Ids, color_vars, frame, rect, solid, text, write_doc
ids = Ids()
# 色板来自 spec §2 D8(oklch 实算)。两条推导必须留下:
# · 底走 H240 冷向、chroma 只有 0.004。数据页面上任何暖底都会让数字读起来
# 「被氛围包装过」;接近中性的冷底是唯一不干扰数值判断的底。
# · 三支航标色**不是配色,是语义**。它们不参与「一主一辅一强调」的分配,
# 只作圆点与药丸;一旦绿柱黄柱红柱同框,语义就被稀释成分类,而分类色恰
# 恰是我们不需要的(图表只用单色阶)。
VARS = color_vars({
"c-wash": "#F1F4F6", # 页面底
"c-panel": "#E4E8EB", # 磁贴 / 卡面
"c-rule": "#CED1D4", # 分隔线 / 表格线
"c-ink": "#19212A", # 主文字 14.71 : wash
"c-ink-soft": "#515961", # 次级文字 6.44
"c-ink-faint": "#737A81", # 标签 / 页码 3.94(仅 ≥24px)
"c-tide": "#006C9B", # 主 accent 5.25
"c-tide-deep": "#004C77", # 承载白字的蓝块 8.27(白字)
"c-tide-wash": "#D6ECF9", # 图表非 key 系列 13.33(ink)
"c-buoy-green": "#35824B", # 语义:正常 4.28
"c-buoy-amber": "#AF7100", # 语义:风险 3.67
"c-buoy-red": "#B24037", # 语义:阻塞 5.16
})
# 高密度档收窄边距,但不越过 §4.1 的 72 软线。下 > 上仍然成立(投影下沿遮挡)。
PAD_TOP, PAD_BOTTOM, PAD_X = 88, 104, 96
INNER = W - PAD_X * 2 # 1728
GUTTER = 16 # 高密度档的呼吸单位比其他档小一级
COLGAP = 24 # 列与列之间的沟槽
# 圆角只有两档:磁贴 8、药丸 18。写成常量是为了让「又多了一个圆角值」在 diff
# 里可数 —— §3.3 把圆角值列进不允许跨页变的一栏。
R_TILE, R_PILL = 8, 18
FS_COVER = 88 # 封面主标
FS_TITLE = 56 # 页标题(比 D7 小一档:密度高,标题必须让位给内容)
FS_LEDE = 36 # 引导句 / 注解标题 / 风险条标题
FS_VALUE = 64 # 磁贴数值(mono)
FS_BODY = 32 # 决议正文
FS_TABLE = 28 # 表格正文 / 表头 / 风险说明
FS_META = 26 # 磁贴副行 / 说明
FS_NOTE = 24 # 标签 / 来源 / 页码
DECK_LABEL = "合章电子签 · 2026 Q2 业务评审"
# ------------------------------------------------------------------ 骨架
def slide(name, *, gap=COLGAP, justify="start"):
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-wash"), 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=COLGAP, 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=1, width="fill_container"):
"""横线用 rectangle,不用 `line`(§6.3:line 在 layout:none 下吃绝对坐标)。"""
return rect(ids, "横线", width=width, height=thickness, fill=solid(color))
def title(content):
return text(ids, "页标题", content, FS_TITLE, 700, "$c-ink", family=CJK,
line_height=lh(FS_TITLE), spacing=track(FS_TITLE), width=INNER)
def note(content):
return text(ids, "来源脚注", content, FS_NOTE, 400, "$c-ink-faint",
family=CJK, line_height=lh(FS_NOTE),
width=cjk_width(FS_NOTE, INNER))
def footer(page):
"""页脚家具。§3.3 把页码位置列进不允许跨页变的一栏,02–07 的 y 完全相同。"""
left = text(ids, "页脚眉标", DECK_LABEL, FS_NOTE, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=lh(FS_NOTE))
folio = text(ids, "页码", f"{page:02d} / 07", FS_NOTE, 500, "$c-ink-faint",
family=NUM, width="fit_content", growth="auto",
line_height=lh(FS_NOTE), spacing=latin_track("07"))
return row("页脚", [left, folio], gap=COLGAP, align="center",
justifyContent="space_between")
def buoy_dot(color, size=12):
return rect(ids, "航标点", width=size, height=size,
cornerRadius=size // 2, fill=solid(color))
def buoy_pill(label, color):
"""航标药丸。高 36 / 圆角 18 —— 24px 中文的行盒就有 34px 高,spec 写的
28 会把字压到贴边。字号地板比装置尺寸硬,让装置。"""
node = frame(ids, "航标药丸", width="fit_content", height=36,
layout="horizontal", padding=[0, 16], gap=0,
cornerRadius=R_PILL, alignItems="center",
justifyContent="center", fill=solid(color))
node["children"] = [
text(ids, "航标文字", label, FS_NOTE, 600, "$c-wash", family=CJK,
width="fit_content", growth="auto", line_height=1.2),
]
return node
# ------------------------------------------------------------------ 01 封面
def cover():
chip = frame(ids, "周期标记", width="fit_content", height="fit_content",
layout="horizontal", padding=[8, 16], gap=0,
cornerRadius=R_TILE, alignItems="center",
justifyContent="center", fill=solid("$c-tide-wash"))
chip["children"] = [
text(ids, "周期文字", "2026 Q2", FS_NOTE, 600, "$c-tide-deep",
family=NUM, width="fit_content", growth="auto", line_height=1.3,
spacing=latin_track("2026 Q2")),
]
lede = col("主张", [
text(ids, "主标题", "签得多了\n但签得更慢了", FS_COVER, 700, "$c-ink",
family=CJK, line_height=lh(FS_COVER), spacing=track(FS_COVER),
width=INNER),
text(ids, "范围说明", "本次评审覆盖企业版四条业务线,数据截至 6 月 30 日。",
FS_BODY, 400, "$c-ink-soft", family=CJK,
line_height=lh(FS_BODY, body=True),
width=cjk_width(FS_BODY, INNER)),
], gap=COLGAP)
# 一条潮痕:整幅横线 + 一段实色刻痕。封面唯一的非文字元素,也是把「刻痕
# 就是上一次的位置」这个锚点先说一遍 —— 后面每一张磁贴都在重复它。
tide_mark = rect(ids, "潮痕刻度", width=220, height=4, fill=solid("$c-tide"))
tide_mark["x"], tide_mark["y"] = 1040, 0
line = rect(ids, "潮痕底线", width=INNER, height=2, fill=solid("$c-rule"))
line["x"], line["y"] = 0, 1
tide = frame(ids, "潮痕", width=INNER, height=4, layout="none", fill=[])
tide["children"] = [tide_mark, line]
meta = row("落款", [
text(ids, "汇报人", "增长与运营部 · 周砚", FS_META, 500, "$c-ink-soft",
family=CJK, width="fit_content", growth="auto",
line_height=lh(FS_META)),
text(ids, "日期", "2026.07.09", FS_META, 500, "$c-ink-faint",
family=NUM, width="fit_content", growth="auto",
line_height=lh(FS_META), spacing=latin_track("2026.07.09")),
], gap=COLGAP, align="center", justifyContent="space_between")
s = slide("01 封面", gap=COLGAP, justify="space_between")
s["content"]["children"] = [chip, lede, col("页尾", [tide, meta],
gap=COLGAP)]
return s
# -------------------------------------------------------------- 02 全景磁贴
# (标签, 数值, 变化, 比较词, 语义, 实际占比, 目标占比)
# 占比是刻度线上的位置,不是数值本身 —— 四个指标量纲不同,能同框比较的只有
# 「离目标多远」这一件事,所以刻度线量的是它。
TILES = [
("季度签署量(万份)", "18.6", "+31%", "对比上季", "green", 0.78, 0.83),
("平均签署时长(小时)", "6.4", "+0.9", "比上季更慢", "amber", 0.80, 0.50),
("一次签成率", "91.2%", "+1.8", "个百分点", "green", 0.91, 0.93),
("已上线集成(个)", "11", "-7", "低于目标", "red", 0.55, 0.90),
]
BUOY = {"green": "$c-buoy-green", "amber": "$c-buoy-amber",
"red": "$c-buoy-red"}
TILE_W = (INNER - COLGAP * 3) // 4 # 414
TILE_PAD = 28
TILE_INNER = TILE_W - TILE_PAD * 2 # 358
TILE_H = 360
def tidemark_scale(value_ratio, target_ratio):
"""潮位刻度线:底线 + 已达段 + 目标竖标。
§6.4:`layout:none` 的 children[0] 在**最上**(与业界惯例相反)。目标竖标
必须压在已达段与底线之上,所以它写在数组第一个 —— 写反了目标就被自己的
进度条盖掉,而「离目标多远」正是这个装置存在的全部理由。
"""
mark = rect(ids, "目标刻痕", width=3, height=14,
fill=solid("$c-tide-deep"))
mark["x"] = round(target_ratio * (TILE_INNER - 3), 2)
mark["y"] = 0
reached = rect(ids, "已达段", width=round(value_ratio * TILE_INNER, 2),
height=2, fill=solid("$c-tide"))
reached["x"], reached["y"] = 0, 6
base = rect(ids, "刻度底线", width=TILE_INNER, height=2,
fill=solid("$c-rule"))
base["x"], base["y"] = 0, 6
node = frame(ids, "潮位刻度", width=TILE_INNER, height=14, layout="none",
fill=[])
node["children"] = [mark, reached, base]
return node
def tidemark_tile(label, value, delta, word, semantic, value_ratio,
target_ratio):
chip = row("变化 chip", [
buoy_dot(BUOY[semantic]),
text(ids, "变化数值", delta, FS_META, 600, "$c-ink", family=NUM,
width="fit_content", growth="auto", line_height=1.3,
spacing=latin_track(delta)),
text(ids, "比较词", word, FS_META, 400, "$c-ink-soft", family=CJK,
width="fit_content", growth="auto", line_height=1.3),
], gap=10, width="fit_content", align="center")
reading = col("读数", [
text(ids, "磁贴数值", value, FS_VALUE, 700, "$c-ink", family=NUM,
line_height=1.05, spacing=latin_track(value, 0)),
tidemark_scale(value_ratio, target_ratio),
], gap=GUTTER + 4, width=TILE_INNER)
# 三段分布而不是顺序堆叠:标签钉在顶、读数居中、变化量钉在底。四张贴子的
# 三条基线因此跨贴对齐,扫一行就能横着比 —— 这是把「列对齐」这条本档承诺
# 从表格延伸到磁贴。
return col("潮位磁贴", [
text(ids, "磁贴标签", label, FS_NOTE, 500, "$c-ink-soft", family=CJK,
line_height=lh(FS_NOTE)),
reading,
chip,
], gap=GUTTER, width=TILE_W, height=TILE_H, justifyContent="space_between",
padding=[TILE_PAD, TILE_PAD], cornerRadius=R_TILE,
fill=solid("$c-panel"))
def overview():
s = slide("02 全景磁贴", gap=COLGAP * 2, justify="space_between")
s["content"]["children"] = [
title("签署量涨了三成,签完的时间也一起涨了"),
row("磁贴行", [tidemark_tile(*t) for t in TILES], gap=COLGAP,
align="stretch"),
col("页尾", [
note("数据来源:合章数据平台,口径为企业版付费租户,"
"2026 年 4 月 1 日至 6 月 30 日。"),
footer(2),
], gap=GUTTER),
]
return s
# -------------------------------------------------------------- 03 趋势页
# 单色阶:只有 key 那两根走 accent,其余走 tide.wash。**不做双色柱对照** ——
# 一旦出现第二支彩色,读者会去找「这两个颜色分别代表什么」,而这张图要说的
# 只有一件事:最后两个月在加速。
TREND = [("一月", "2.4", 258, False), ("二月", "2.9", 312, False),
("三月", "2.7", 291, False), ("四月", "3.2", 345, False),
("五月", "3.4", 366, True), ("六月", "3.9", 420, True)]
CHART_W = 1240
ANNOTATE_W = INNER - CHART_W - COLGAP * 2 # 440
def trend_chart():
bar_w = (CHART_W - COLGAP * (len(TREND) - 1)) // len(TREND)
bars = []
for label, value, height, key in TREND:
bars.append(col(f"{label} 柱", [
# 数值直接标在柱端,不做 y 轴、不做网格线、不做图例(§3.6)。
text(ids, "柱值", value, FS_META, 600,
"$c-ink" if key else "$c-ink-soft", family=NUM,
line_height=1.2, align="center", spacing=latin_track(value)),
rect(ids, "月柱", width=bar_w, height=height,
fill=solid("$c-tide" if key else "$c-tide-wash")),
text(ids, "月份", label, FS_NOTE, 400, "$c-ink-faint", family=CJK,
line_height=lh(FS_NOTE), align="center"),
], gap=8, width=bar_w, align="center", justifyContent="end",
height="fill_container"))
return row("趋势柱图", bars, gap=COLGAP, width=CHART_W,
height=420 + 34 + 36 + 16, align="end")
TREND_NOTES = [
("中小客户是自己找上门的", "六月新签里 71% 没有销售触达,\n来自模板市场与搜索。"),
("大客户的量卡在集成上", "两家头部客户的用印审批\n还在他们自己的 OA 里。"),
]
def trend():
blocks = []
for head, desc in TREND_NOTES:
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_META, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_META, body=True),
width=ANNOTATE_W),
], gap=8))
s = slide("03 趋势", gap=COLGAP * 2, justify="space_between")
s["content"]["children"] = [
col("论证", [
title("签署量每个月都在涨,涨的全是中小客户"),
row("趋势区", [trend_chart(),
col("注解栏", blocks, gap=COLGAP,
width=ANNOTATE_W)],
gap=COLGAP * 2, align="start"),
], gap=COLGAP * 2),
col("页尾", [
note("数据来源:合章数据平台,按签署完成时间归月,单位 万份。"),
footer(3),
], gap=GUTTER),
]
return s
# -------------------------------------------------------------- 04 明细表
# 列宽写死并且必须加起来等于 INNER。§6.10:渲染栈不保证 tabular 数字,日期列
# 与状态列都靠定宽站住,不能指望 fit_content 让它们自己对齐。
DETAIL_COLS = [420, 200, 220, 220, 572]
assert sum(DETAIL_COLS) + COLGAP * (len(DETAIL_COLS) - 1) == INNER
DETAIL_HEAD = ["集成对象", "状态", "责任人", "到期", "当前卡点"]
# 母题 2 的硬规则:**带航标点的行必须同时有责任人与到期**。这张表的四列结构
# 就是那条规则的物理形态 —— 缺任一列,这一行的状态就是无主的。
DETAIL_ROWS = [
("钉钉待办同步", "正常", "green", "陆迎", "07-18", "联调已过,等对方发版"),
("企业微信审批", "正常", "green", "陆迎", "07-25", "灰度中,两家客户在跑"),
("金蝶云星空", "风险", "amber", "沈知白", "07-11", "对方接口文档改了三次"),
("用友 U8C", "风险", "amber", "沈知白", "07-04", "已逾期一周,等对方排期"),
("飞书多维表", "正常", "green", "高见", "08-08", "需求已确认,尚未开工"),
("泛微 OA", "阻塞", "red", "高见", "06-20", "对方要求私有化部署"),
("致远 A8", "阻塞", "red", "关屿", "06-27", "客户侧改了用印流程"),
]
def detail_table():
head = row("表头", [
text(ids, "表头单元", label, FS_TABLE, 600, "$c-ink-soft", family=CJK,
line_height=lh(FS_TABLE), width=width)
for label, width in zip(DETAIL_HEAD, DETAIL_COLS)
], gap=COLGAP, align="center")
parts = [head, hrule("$c-tide", 2)]
for index, (name, state, semantic, owner, due, blocker) in enumerate(
DETAIL_ROWS):
status = row("状态单元", [
buoy_dot(BUOY[semantic]),
text(ids, "状态文字", state, FS_TABLE, 500, "$c-ink", family=CJK,
width="fit_content", growth="auto", line_height=lh(FS_TABLE)),
], gap=10, width=DETAIL_COLS[1], align="center")
parts.append(row("表行", [
text(ids, "集成名", name, FS_TABLE, 500, "$c-ink", family=CJK,
line_height=lh(FS_TABLE), width=DETAIL_COLS[0]),
status,
text(ids, "责任人", owner, FS_TABLE, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_TABLE), width=DETAIL_COLS[2]),
text(ids, "到期", due, FS_TABLE, 500, "$c-ink-soft", family=NUM,
line_height=lh(FS_TABLE), width=DETAIL_COLS[3],
spacing=latin_track(due)),
text(ids, "卡点", blocker, FS_TABLE, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_TABLE), width=DETAIL_COLS[4]),
], gap=COLGAP, align="center"))
# 末行无线:最后一条线画出去,表就读作一个被框住的块,而不是一列还能
# 往下续的记录。
if index < len(DETAIL_ROWS) - 1:
parts.append(hrule())
return col("集成明细", parts, gap=GUTTER - 4)
def detail():
s = slide("04 明细表", gap=COLGAP * 2, justify="space_between")
s["content"]["children"] = [
col("论证", [title("十一个集成里,四个已经逾期"), detail_table()],
gap=COLGAP * 2),
col("页尾", [
note("数据来源:集成交付看板,状态截至 7 月 8 日 18:00。"),
footer(4),
], gap=GUTTER),
]
return s
# -------------------------------------------------------------- 05 风险页
RISK_STATE_W = 140
RISK_TEXT_W = 1180
RISKS = [
("阻塞", "red", "两家 OA 厂商要求私有化部署",
"涉及四家年费合计两千四百万的客户,都在等这条。",
"八月前给出私有化版本的报价与交付边界。", "关屿", "08-15"),
("风险", "amber", "平均签署时长从 4.1 小时涨到 6.4",
"投诉集中在等对方签,不在我们这一段。",
"九月上线到期提醒与代签授权,先在华东试。", "周砚", "09-30"),
("风险", "amber", "模板市场的增长没有人守",
"六月 71% 的新签来自这里,但它没有归属团队。",
"七月内定归属,先归运营,季度末重评。", "周砚", "07-31"),
]
def risk():
items = []
for state, semantic, head, impact, plan, owner, due in RISKS:
if items:
items.append(hrule())
# 责任人与到期紧跟航标点,同一行右端 —— 母题 2 的硬规则在这一页的形态。
owner_line = row("归属", [
text(ids, "责任人", owner, FS_META, 500, "$c-ink-soft",
family=CJK, width="fit_content", growth="auto",
line_height=lh(FS_META)),
text(ids, "到期", due, FS_META, 500, "$c-ink-faint", family=NUM,
width="fit_content", growth="auto", line_height=lh(FS_META),
spacing=latin_track(due)),
], gap=GUTTER, width="fit_content", align="center")
items.append(row("风险条", [
col("状态列", [buoy_pill(state, BUOY[semantic])], gap=0,
width=RISK_STATE_W),
col("风险文案", [
text(ids, "风险标题", head, FS_LEDE, 600, "$c-ink",
family=CJK, line_height=lh(FS_LEDE),
width=cjk_width(FS_LEDE, RISK_TEXT_W)),
text(ids, "风险影响", impact, FS_TABLE, 400, "$c-ink-soft",
family=CJK, line_height=lh(FS_TABLE, body=True),
width=cjk_width(FS_TABLE, RISK_TEXT_W)),
text(ids, "风险应对", plan, FS_TABLE, 400, "$c-ink",
family=CJK, line_height=lh(FS_TABLE, body=True),
width=cjk_width(FS_TABLE, RISK_TEXT_W)),
], gap=8, width=RISK_TEXT_W),
col("归属列", [owner_line], gap=0, width="fill_container",
align="end"),
], gap=COLGAP, align="start"))
s = slide("05 风险", gap=COLGAP * 2, justify="space_between")
s["content"]["children"] = [
col("论证", [title("三个风险,两个我们自己能解"),
col("风险列表", items, gap=COLGAP + 16)], gap=COLGAP * 2),
footer(5),
]
return s
# -------------------------------------------------------------- 06 泳道路线
LANE_NAME_W = 200
LANE_TIME_W = INNER - LANE_NAME_W - COLGAP # 1504
LANE_H = 56
BAR_H = 40
MONTHS = ["七月", "八月", "九月"]
# (泳道名, 起点, 终点, 里程碑) —— 比例是相对整个季度的位置。
LANES = [
("集成交付", 0.00, 0.83, 0.66),
("私有化版本", 0.16, 1.00, 0.97),
("签署提速", 0.33, 1.00, 0.72),
("模板市场归属", 0.00, 0.44, 0.41),
]
def lane_track(start, end, milestone):
"""一条泳道。
条用 frame 而不是 rectangle:左端那 6px 实色段是**子节点**,而
§6.13「rectangle 是容器但不递归渲染子节点」意味着放进 rect 里的东西不会
显示。clipContent 让实色段跟着圆角收边。
月份分隔用一串短 rectangle 拼的虚线(§6.1:`dashPattern` 是死字段)。
里程碑是旋转 45° 的方块而不是圆点:**里程碑与状态点必须形状可辨**,不能
只靠颜色 —— 颜色是投影衰减与色觉障碍下最先失效的那一维。
"""
bar_x = round(start * LANE_TIME_W, 2)
bar_w = round((end - start) * LANE_TIME_W, 2)
bar = frame(ids, "泳道条", width=bar_w, height=BAR_H, layout="horizontal",
gap=0, cornerRadius=R_TILE, clipContent=True,
fill=solid("$c-tide-wash"))
bar["children"] = [rect(ids, "起点段", width=6, height=BAR_H,
fill=solid("$c-tide"))]
bar["x"], bar["y"] = bar_x, (LANE_H - BAR_H) / 2
flag = rect(ids, "里程碑", width=16, height=16, rotation=45,
fill=solid("$c-tide-deep"))
flag["x"] = round(milestone * LANE_TIME_W - 8, 2)
flag["y"] = (LANE_H - 16) / 2
kids = [flag, bar]
for index in (1, 2):
dash = dash_col(rect, ids, "月份分隔", "$c-rule", length=LANE_H)
dash["x"] = round(index * LANE_TIME_W / 3, 2)
dash["y"] = 0
kids.append(dash)
cell = frame(ids, "泳道时间区", width=LANE_TIME_W, height=LANE_H,
layout="none", fill=[])
cell["children"] = kids
return cell
def roadmap():
month_w = (LANE_TIME_W - GUTTER * 2) // 3
months = row("月份头", [
frame(ids, "月份头占位", width=LANE_NAME_W, height=1, layout="none",
fill=[]),
row("月份标签", [
text(ids, "月份名", label, FS_NOTE, 500, "$c-ink-faint",
family=CJK, line_height=lh(FS_NOTE), width=month_w,
align="center")
for label in MONTHS
], gap=GUTTER, width=LANE_TIME_W),
], gap=COLGAP, align="center")
lanes = []
for name, start, end, milestone in LANES:
lanes.append(row("泳道", [
text(ids, "泳道名", name, FS_META, 500, "$c-ink", family=CJK,
line_height=lh(FS_META), width=LANE_NAME_W),
lane_track(start, end, milestone),
], gap=COLGAP, align="center"))
flag = rect(ids, "图例里程碑", width=16, height=16, rotation=45,
fill=solid("$c-tide-deep"))
sample = frame(ids, "图例条", width=48, height=20, layout="horizontal",
gap=0, cornerRadius=R_TILE, clipContent=True,
fill=solid("$c-tide-wash"))
sample["children"] = [rect(ids, "图例起点段", width=6, height=20,
fill=solid("$c-tide"))]
legend = row("图例", [
sample,
text(ids, "图例文字", "进行中", FS_NOTE, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=lh(FS_NOTE)),
frame(ids, "图例间隔", width=24, height=1, layout="none", fill=[]),
flag,
text(ids, "图例文字", "里程碑", FS_NOTE, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=lh(FS_NOTE)),
], gap=12, width="fit_content", align="center")
s = slide("06 泳道路线", gap=COLGAP * 2, justify="space_between")
s["content"]["children"] = [
title("三季度只做三件事,其余往后放"),
col("泳道区", [months] + lanes, gap=COLGAP),
col("页尾", [legend, footer(6)], gap=COLGAP),
]
return s
# -------------------------------------------------------------- 07 决议页
DECISIONS = [
("私有化版本做不做,\n做到什么边界", "关屿", "07-12"),
("模板市场归运营\n还是归产品", "周砚", "07-12"),
("两家逾期集成\n是否换供应商", "沈知白", "07-19"),
]
def resolution():
card_w = (INNER - COLGAP * 2) // 3
cards = []
for line, owner, due in DECISIONS:
cards.append(col("待决事项", [
hrule("$c-tide", 2, width=card_w),
text(ids, "决议", line, FS_BODY, 600, "$c-ink", family=CJK,
line_height=lh(FS_BODY, body=True),
width=cjk_width(FS_BODY, card_w)),
row("决策归属", [
text(ids, "决策人", owner, FS_META, 500, "$c-ink-soft",
family=CJK, width="fit_content", growth="auto",
line_height=lh(FS_META)),
text(ids, "期限", due, FS_META, 500, "$c-ink-faint",
family=NUM, width="fit_content", growth="auto",
line_height=lh(FS_META), spacing=latin_track(due)),
], gap=GUTTER, width="fit_content", align="center"),
], gap=COLGAP, width=card_w))
s = slide("07 决议", gap=COLGAP * 2, justify="space_between")
s["content"]["children"] = [
title("这三件今天要定,定不了就带走"),
row("待决区", cards, gap=COLGAP, align="start"),
col("页尾", [
text(ids, "收束句", "下个季度的增长在集成上,不在获客上。",
FS_LEDE, 500, "$c-ink-soft", family=CJK,
line_height=lh(FS_LEDE, body=True),
width=cjk_width(FS_LEDE, INNER)),
footer(7),
], gap=COLGAP),
]
return s
assert_type_scale([FS_COVER, FS_TITLE, FS_LEDE, FS_VALUE, FS_BODY, FS_TABLE,
FS_META, FS_NOTE], where="tidemark-slate-deck")
def build():
boards = [cover(), overview(), trend(), detail(), risk(), roadmap(),
resolution()]
for board in boards:
board.pop("content", None)
place_boards(boards)
# 本档只批准两个圆角值:磁贴 8、药丸 18。多出第三个就说明有人又发明了一
# 档,§3.3 把圆角值列进「不允许跨页变」的一栏,这里是那条的执行点。
return audit_document(boards, where="tidemark-slate-deck",
allowed_radii={R_TILE, R_PILL})
# 对比度(WCAG 相对亮度比,实算):
# c-ink on c-wash 14.71 c-ink on c-panel 13.19
# c-ink-soft on c-wash 6.44 c-ink-soft on c-panel 5.77
# c-ink-faint on c-wash 3.94 ← 只承载 ≥24px 的标签/来源/页码
# c-tide on c-wash 5.25 c-ink on c-tide-wash 13.33
# c-wash on c-buoy-green 4.27 c-wash on c-buoy-amber 4.05
# c-wash on c-buoy-red 5.70
# 琥珀从 L0.660 压到 L0.600 换来 3.67 : wash —— 一支只在色相上成立、明度上读
# 不清的语义色,在色觉障碍与投影衰减下会整支失效。药丸上它承载的是反白文字,
# 那一对是 4.05。
# `c-rule` 与刻度底线是**线不是字**(§4.4 对比豁免第 2 条),不参与对比核算。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "潮痕石板 · 数据评审档 16:9")

View file

@ -0,0 +1,327 @@
"""D7/D8 两套 deck 的共享度量层 —— 画幅、板位、字阶行高、CJK 行长、虚线。
来源是 `openpencil-docs/openpencil/generation/deck-system-0809.md` 的 §3
(deck 通用契约)与 §6(工程约束移交)。cardlib 是卡片体系的度量层、只被
tpl_*_card.py 引用;这一层是 **deck 体系专用**,只被 deck_*.py 引用。两者
都建在 oplib 的节点构造层之上,互不依赖。
把它单独拆出来而不是各自抄一遍,是因为 §3 的三条铁律里有两条(字号地板、
CJK 行长)是**算出来的数**而不是记住的数:抄第二遍就会有第二个版本。
### spec 的两处算术需要说明
§2 通用前提写「D7 12 列 × 128 + 11 沟槽 × 24 = 1680」与「D8 12 列 × 132 +
11 沟槽 × 24 = 1728」。两式都不自洽(12×128+264 = 1800、12×132+264 = 1848)。
可信的是**内容宽**(由边距推出:1920−240 = 1680、1920−192 = 1728)与**沟槽
24**,列宽是被这两个数推出来的余数:D7 col = (1680−264)/12 = 118,D8 col =
(1728−264)/12 = 122。本文件按内容宽与沟槽算列,不用 spec 写的列宽字面值。
"""
# ---------------------------------------------------------------- 画幅与板位
#
# 1920×1080 固定舞台。投影比例是硬约束,任何一页 fit_content 都会让这套 deck
# 在放映时变成不同尺寸的纸。
W, H = 1920, 1080
# §6.15:3 板一行,x 步长 2040、y 步长 1440。y 步长比 x 多 240 不是手滑 ——
# 画布在帧上方以**屏幕空间**固定偏移画帧名,deck 缩到能整屏看时 120 文档像素
# 只剩十几个屏幕像素,第二行的帧名会压到上一行的板上。
BOARDS_PER_ROW = 3
BOARD_STRIDE_X = 2040
BOARD_STRIDE_Y = 1440
CJK = "Noto Sans SC"
NUM = "Inter"
def place_boards(boards):
"""给每页写死 x/y。
顶层 frame 缺 x/y 时全部落在原点,七页会叠成一页、另外六页藏在下面。
"""
for index, board in enumerate(boards):
board["x"] = (index % BOARDS_PER_ROW) * BOARD_STRIDE_X
board["y"] = (index // BOARDS_PER_ROW) * BOARD_STRIDE_Y
return boards
# ---------------------------------------------------------------- 行高
#
# §3.5 的行高按**字号**分段,不按「标题/正文」分类 —— 这正是 §5.3 C1 要修的
# 那处语料冲突:1.3–1.4 对 24–40px 的界面标题正确,对 88–116px 的 deck
# display 会撑出一整行的空洞。
LH_DISPLAY_XL = 1.08 # ≥88px
LH_DISPLAY = 1.12 # 64–87px
LH_TITLE = 1.20 # 48–63px
LH_SUBTITLE = 1.35 # 40–47px
LH_BODY = 1.75 # CJK 正文(西文 1.5–1.6 → 中文 +0.2)
LH_NOTE = 1.50 # 注释 / 来源 / 页码
def lh(size, *, body=False):
"""按字号取行高。`body=True` 表示这块是成段中文,走 1.75 而不是标签行高。"""
if size >= 88:
return LH_DISPLAY_XL
if size >= 64:
return LH_DISPLAY
if size >= 48:
return LH_TITLE
if size >= 40:
return LH_SUBTITLE
if body:
return LH_BODY if size >= 28 else LH_NOTE
return 1.40
# ---------------------------------------------------------------- 字距
#
# §3.8.2:我们的 schema 里 letterSpacing 是 **px 绝对值不是 em**。
# <48px 一律 0,绝不为负 —— 负字距在 CJK 正文上直接让字面相撞。
# ≥64px 允许 round(fontSize × -0.02),且不得更负。
# 大写拉丁小标签(不含汉字)允许 +1~+2 的正字距;含汉字的一律 0。
DISPLAY_TRACK = -0.02
def track(size):
"""display 档的负字距。48–63px 落在两条规则之间,取 0(保守侧)。"""
return round(size * DISPLAY_TRACK) if size >= 64 else 0
def latin_track(content, value=1.0):
"""纯拉丁/数字小标签才给正字距;混了汉字就归零。"""
return value if all(ch < "⺀" for ch in content) else 0
# ---------------------------------------------------------------- CJK 行长
#
# §3.8.3:CJK 正文每行 ≤30 汉字。1680 的内容宽在 32px 字号下是 52 字/行,
# **远超舒适区** —— 所以正文块必须自己收窄,不能因为容器有那么宽就排那么宽。
# 这是本体系里唯一一条「容器宽 ≠ 文本宽」的硬规则。
CJK_MAX_CHARS = 30
def cjk_width(size, cap=None):
"""给定字号下,一行不超过 30 汉字的最大文本宽。`cap` 是容器给的上限。"""
limit = size * CJK_MAX_CHARS
return min(limit, cap) if cap else limit
# ---------------------------------------------------------------- 字号地板
#
# §3.4 的硬 FAIL 线。这里只做断言用 —— 产稿时把每一个字号过一遍,比渲染完
# 再看检测器报告便宜得多。
FONT_HARD_FLOOR = 20
FONT_WARN_FLOOR = 28
DECK_MIN_MAX_FONT = 60
def assert_type_scale(sizes, *, where):
"""字号地板自检:任何 <20 直接抛;全 deck 最大字号 <60 也抛。
「全 deck 最大字号 <60」查的不是某一个节点,是**层级缺失** —— 一份连一个
60px 以上标题都没有的 deck,说明它被平铺成了文档。
"""
for size in sizes:
if size < FONT_HARD_FLOOR:
raise ValueError(f"{where}: {size}px < {FONT_HARD_FLOOR} 硬地板")
if max(sizes) < DECK_MIN_MAX_FONT:
raise ValueError(f"{where}: 最大字号 {max(sizes)} < {DECK_MIN_MAX_FONT}")
body = min(s for s in sizes if s >= FONT_WARN_FLOOR)
if max(sizes) < body * 2.5:
raise ValueError(f"{where}: 最大字号 {max(sizes)} < 2.5 × 正文 {body}")
return sizes
# ---------------------------------------------------------------- 虚线
#
# §6.1:`dashPattern` 是死字段,schema 里有、渲染栈不认。写了只会得到一条实线
# 或者什么都没有。所有虚线必须用**一串短 rectangle** 拼。
#
# 拼法用 flex 而不是绝对坐标:一串等距短段正好是「gap 固定的一行/一列」,
# 交给布局算比自己写 N 个 x 少一个出错面。横向那一版按需再加:本批只有
# D8 的月份分隔用得上竖虚线。
def dash_col(rect_fn, ids, name, color, *, length, dash=8, gap=6,
thickness=1):
"""竖直虚线:一列等距短段,交给 flex 排。"""
from oplib import frame, solid
count = max(1, (length + gap) // (dash + gap))
node = frame(ids, name, width="fit_content", height="fit_content",
layout="vertical", gap=gap, alignItems="center", fill=[])
node["children"] = [
rect_fn(ids, f"{name} · 段", width=thickness, height=dash,
fill=solid(color))
for _ in range(count)
]
return node
# ---------------------------------------------------------------- 产稿期自检
#
# 这一节是把「事后 grep 核对负约束」提前成**生成期断言**。差别不在勤快:
# grep 是一次性的,改一句文案没人会重跑;断言挂在 build() 上,任何一次改稿
# 都必须先过它。同理,CJK 折行也不该等 cjkreal 闸来报 —— 闸要跑 MCP、要渲染,
# 一轮几十秒;同一个折行模型写进这里,改文案当场就红。
#
# 三条判据的来源:
# · 负约束 spec §2 各档 Strictly avoid + §3.7 的 AI-slop 清单
# · 折行模型 qa/cjkcheck.py(每行容量 = 文本宽 // 字号,同时试 per 与 per+1)
# · 字距/字族 §3.8
NO_LINE_START = ",。、;:?!)」』】》〉·…—"
ALLOWED_FAMILIES = {"Noto Sans SC", "Inter"}
# 「汉字占比 <0.6 就不按 1em/字 估」与 cjkcheck 同口径:拉丁半宽,混排串按
# 全宽算会得到一堆假阳性。
CJK_RATIO_GATE = 0.6
def _flatten(node, out):
if isinstance(node, dict):
out.append(node)
for kid in node.get("children") or []:
_flatten(kid, out)
elif isinstance(node, list):
for kid in node:
_flatten(kid, out)
return out
def _char_em(ch):
"""一个字符占几个 em。
cjkcheck 按「1em 一个字」估,那是**故意保守**的:它是闸的第一道,宁可多
报也不漏,误报再交给 cjkreal 用真实渲染高度筛掉(gate.sh 记的实测是 13 报
→ 11 真)。产稿期这道断言不能这么干 —— 它会在中英混排的句子上无差别拦
人,逼着为一个并不存在的折行去改文案。
所以这里按真实字宽建模:CJK 全宽、拉丁与数字约半宽。U+2E80 以下走 0.55,
以上(含 U+FF0C 这类全角标点)走 1.0。
"""
return 0.55 if ch < "⺀" else 1.0
def _wrap(content, width_em):
"""按字宽贪心折行,显式 \\n 强制断行。返回 [(行, 是否引擎折的)]。
`soft` 这一位是关键:作者写 \\n 让某几个字独占一行是版式意图,引擎把
「。」甩到下一行才是事故。孤字规则只对 soft 行成立。
"""
lines = []
for hard in content.split("\n"):
if not hard:
lines.append(("", False))
continue
line, used, soft = "", 0.0, False
for ch in hard:
em = _char_em(ch)
if line and used + em > width_em:
lines.append((line, soft))
line, used, soft = ch, em, True
else:
line += ch
used += em
lines.append((line, soft))
return lines
def audit_document(children, *, where, allowed_radii=frozenset()):
"""产稿期终审。任何一条不过就抛,绝不产出半合格的 .op。
`allowed_radii` 是这一档**显式批准**的圆角值集合。正圆(cornerRadius 恰为
宽高之半且宽高相等)永远放行 —— 「不用圆角」约束的是圆角矩形容器,圆点
要圆就必须写这个字段,两者不是一回事。
"""
nodes = _flatten(children, [])
problems = []
for node in nodes:
name = node.get("name", node.get("id", "?"))
# —— 负约束:这些字段一旦出现,档位身份就变了
for dead in ("shadow", "opacity", "dashPattern"):
if dead in node:
problems.append(f"{name}: 出现禁用字段 {dead}")
if node.get("type") == "line":
problems.append(f"{name}: 用了 line 节点(§6.3 一律走 rectangle)")
for paint in (node.get("fill") or []):
if isinstance(paint, dict):
if "gradient" in str(paint.get("type", "")):
problems.append(f"{name}: 出现渐变填充")
color = paint.get("color")
if isinstance(color, str) and color.startswith("#"):
problems.append(f"{name}: 写死 hex {color},未走设计变量")
# —— 圆角:只放行本档批准的值与正圆
radius = node.get("cornerRadius")
if radius:
w, h = node.get("width"), node.get("height")
round_dot = (isinstance(w, (int, float)) and w == h
and abs(radius - w / 2) < 0.51)
if not round_dot and radius not in allowed_radii:
problems.append(f"{name}: cornerRadius={radius} 不在本档批准的 "
f"{sorted(allowed_radii) or '零圆角'} 内,也不是正圆")
if node.get("type") != "text":
continue
size = node.get("fontSize")
content = node.get("content")
width = node.get("width")
if node.get("fontFamily") not in ALLOWED_FAMILIES:
problems.append(f"{name}: 字族 {node.get('fontFamily')} 不在保底两支内")
if size < FONT_HARD_FLOOR:
problems.append(f"{name}: {size}px < {FONT_HARD_FLOOR} 硬地板")
# —— fit_content 与 growth 必须成对。配成 fixed-width 时 jian 会把节点
# 压得比内容还窄,文字折出孤字末行 —— 而且它在 audit 上是干净的,
# 只有 CJK 闸或出图才看得见。
auto = node.get("textGrowth") == "auto"
if (width == "fit_content") != auto:
problems.append(f"{name}: width={width!r} 与 growth="
f"{node.get('textGrowth')!r} 不成对")
# —— 字距:<64px 一律 0 且绝不为负;≥64px 不得比 -0.02em 更负
spacing = node.get("letterSpacing", 0)
if spacing < 0 and (size < 64 or spacing < round(size * DISPLAY_TRACK)):
problems.append(f"{name}: {size}px 的负字距 {spacing} 越界")
if not isinstance(content, str) or not isinstance(width, (int, float)):
continue
han = sum(1 for ch in content if "一" <= ch <= "鿿")
if not content.strip() or han / len(content) < CJK_RATIO_GATE:
continue
# —— 行长 ≤30 汉字。量的是「这个宽度最多能排几个全宽字」,与实际内容
# 无关 —— 它约束的是版式,不是这一句文案。
#
# 只对正文档(<48px)成立。§3.8.3 那条规则解决的是「读到行尾找不回下一
# 行开头」,那是成段文字的问题;display 档本来就只有十几个字、一到两
# 行,把它按 30 字卡宽只会逼出一堆无意义的定宽。
capacity = width / size
if size < 48 and capacity > CJK_MAX_CHARS:
problems.append(f"{name}: 每行可排 {capacity:.0f} 字 > "
f"{CJK_MAX_CHARS} 上限")
# —— 折行模型:容量本身与再紧一档都要干净。末字标点悬挂会让实际每行多
# 塞进小半个字,只按标称容量试会漏掉正好卡在边界上的那一类。
for room in (capacity, capacity - 0.5):
if room < 1:
continue
lines = _wrap(content, room)
if len(lines) < 2:
continue
for index, (line, soft) in enumerate(lines[1:], 1):
if soft and line and line[0] in NO_LINE_START:
problems.append(f"{name}: 第 {index + 1} 行起于 "
f"{line[0]!r}(每行 {room:.1f} 字)")
tail, tail_soft = lines[-1]
if tail_soft and 0 < len(tail) <= 4:
problems.append(f"{name}: 孤字末行 {tail!r}"
f"(每行 {room:.1f} 字)全文={content[:24]!r}")
if problems:
raise ValueError(f"{where} 产稿自检未过({len(problems)} 条):\n "
+ "\n ".join(dict.fromkeys(problems)))
return children

View file

@ -0,0 +1,573 @@
#!/usr/bin/env python3
"""banxin-rule-deck.op — 版心界行 · 中文原生排版档(7 页 1920×1080)
spec: openpencil-docs/openpencil/generation/deck-system-0809.md §2 · D9
落格:medium-high formality × medium-high density × **mixed**(深浅书挡)
这一档跟仓里其它 deck 的分野只有一句话:**中文正文本身是主角**。所以它
不是「中国风模板」——没有水墨、没有印章、没有回纹。身份全部来自版面几何:
版心 一页里可以落墨的那块地,天头 144 / 地脚 108,**上大于下**
界行 分栏用的 1px 竖线,贯穿固定高度,不随文字长短伸缩
天头 批注带(注疏层),正文之外的第三层文本层级
鱼尾 地脚居中的页码标记,每页复现,是跨页恒定的身份锚
### 三条硬规则(这一档的命)
1. **正文每行 ≤30 汉字。** 1680 的版心在 32px 下是 52 字/行,远超舒适区,
所以正文块一律收窄到 960(=30 字)或分两栏 —— 「界行」这个母题存在的
功能理由就在这里,它不是装饰。
2. **中文标题绝不用省略号截断,也绝不交给自动折行。** 所有断点在文案里写
死 `\\n`;`cjk_text()` 会在产稿时逐行断言字数没有越过该块的容量,越了
直接 AssertionError,而不是等 QA 闸去抓。
3. **行高按字号分段,不按「标题/正文」分类**(spec §3.5 / §5.3 C1):
≥64 → 1.02-1.15;48-63 → 1.15-1.25;40-47 → 1.3-1.4;正文 → 1.7-1.8。
字距同理(§5.3 C2):<48 一律 0 绝不为负;≥64 才允许 round(fs×-0.02)。
### 工程约束(spec §6,逐条对应过一次真实事故)
- 6.1 `dashPattern` 是死字段 —— 本档零虚线,界行全部实线。
- 6.2 相邻同色块之间要有真格线 —— 表格行靠斑马 + 1px 界行,且行内单元格
之间给 24 的真 gap(`SIBLING_JAM_GAP = 3` 的余量),不靠 padding 撑。
- 6.3 `line` 在 layout:none 下吃文档绝对坐标 —— 界行、朱砂短线、表格线全部
用 `rectangle` 画,一根 `line` 都没有。
- 6.4 layout:none 的 children[0] 在最上 —— 每页先写内容、后写装饰。
- 6.9 字体两层,产稿一律用保底层:`Noto Sans SC` + `Inter`。首选的源流明体
/ 霞鹜文楷不在渲染字体包内,写了会静默回退并触发「量 A 画 B」的裁切。
- 6.13 rectangle 不递归渲染子节点 —— 引文块、朱砂淡底块一律 `frame`。
- 6.15 板位 x = (i%3)*2040、y = (i//3)*1440。
### 深浅书挡
深底只在 01/02(开头)与 07(结尾)出现,中间四页全亮底,绝不交替
(spec §2 D9 Strictly avoid 8)。
对比度实测见文件末尾,最低一对 3.72(`ink.faint`,只承载 ≥24px 的批注/
页码,属 spec §4.4 的合法例外)。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import Ids, color_vars, frame, path, rect, solid, text, write_doc
ids = Ids()
# ---------------------------------------------------------------- 色板
#
# spec §2 D9 的 oklch 表逐条换算而来(hex 由 spec 给出,未再自行取整)。
# 冷暖分裂是这一档的物理关系:纸(H86-88 暖)对墨(H60 暖)对函套(H60 暖)
# —— 全暖向,因为纸面反射是暖的,纯黑写在暖白纸上会「浮」起一层。
VARS = color_vars({
"c-xuan": "#F3F0E9", # 宣白 · 内容页底
"c-xuan-deep": "#E5E1D7", # 引文块 / 表格斑马
"c-case": "#1E1813", # 函套墨 · 封面/篇目/收束页底
"c-ink": "#2A241F", # 主文字
"c-ink-soft": "#5F5A55", # 次级文字
"c-ink-faint": "#807A76", # 批注 / 页码(仅 ≥24px)
"c-xuan-on": "#F0ECE7", # 深页主文字
"c-xuan-dim": "#B4B0AA", # 深页次级文字
"c-jie": "#D0CBC5", # 界行线(非文字)
"c-cinnabar": "#AA4331", # 唯一强调:鱼尾 / 页码标记 / 单页一处
"c-cinnabar-wash": "#F9D7CE", # 朱砂淡版(承载墨字)
})
W, H = 1920, 1080
# 天头 144 / 地脚 108,比例 1.33:1。古籍如此是为了留批注;我们保留它有两个
# 当代理由 —— 投影下沿常被人头遮挡,且上下不对称当场打破「万物居中且四边
# 等距」这条 AI 指纹(spec §3.7 第 8 条)。
HEAD, FOOT, SIDE = 144, 108, 120
BANXIN_TOP, BANXIN_BOTTOM = HEAD, H - FOOT # 144 .. 972
INNER = W - SIDE * 2 # 1680
# 界行的固定跨度。**不随文字长短伸缩**是这个母题的定义性质,所以它是常量
# 而不是算出来的:从页标题下方一格起,到地脚上沿止,每一页同一段。
RULE_TOP, RULE_BOTTOM = 264, BANXIN_BOTTOM
RULE_H = RULE_BOTTOM - RULE_TOP # 708
# 正文行长闸:30 汉字。所有正文块宽度由它反推,不由容器宽度决定。
MAX_HAN_PER_LINE = 30
BODY_FS = 32
BODY_W = MAX_HAN_PER_LINE * BODY_FS # 960
CJK = "Noto Sans SC"
NUM = "Inter"
# ---------------------------------------------------------------- 字阶
#
# (fontSize, lineHeight, letterSpacing)。分段规则见模块 docstring 第 3 条。
COVER = (96, 1.08, -2) # round(96 × -0.02) = -2,不得更负
CLOSE = (64, 1.12, -1) # round(64 × -0.02) = -1
TITLE = (56, 1.20, 0) # 48-63 档,字距 0
LEAD = (40, 1.35, 0) # 篇名 / 栏题
BODY = (BODY_FS, 1.75, 0) # 正文,CJK 1.7-1.8
QUOTE = (36, 1.80, 0) # 引文
CELL = (28, 1.70, 0) # 表格正文
NOTE = (26, 1.70, 0) # 批注 / 旁注 / 出处
FOLIO = (24, 1.20, 0) # 页码 / 落款 mono
def th(step, lines=1):
"""一个字阶排 n 行的渲染高度。jian 量文字高度就是 fs × lh × 行数,
绝对定位的每一块都靠它反推 y —— 这一档没有一处靠「试出来」的坐标。"""
size, line_height, _ = step
return round(size * line_height * lines)
# ---------------------------------------------------------------- 文本
def cjk_text(name, content, step, weight, color, *, inner, family=CJK,
align=None, width="fill_container"):
"""一个中文文本节点,**产稿时就把行长闸执行掉**。
`inner` 是这个节点真正能占的像素宽(父块宽减 padding)。断言按 QA 闸
`qa/cjkcheck.py` 的同一个模型来算:每字 1em、逐字符断行、每行容量
`inner // fontSize`。只要每一条硬换行都不越容量,引擎就不会再产生任何
「软折行」,行首标点与孤字末行两类事故因此在源头就不可能发生 ——
QA 闸是复核,不是防线。
注意 cjkcheck 只检查汉字占比 ≥0.6 的内容(拉丁半宽,按 1em 算是假阳
性),所以断言也照同一个门控走。
"""
size, line_height, spacing = step
per = int(inner // size)
han = sum(1 for ch in content if "一" <= ch <= "鿿")
body = content.replace("\n", "")
if body and han / len(body) >= 0.6:
for line in content.split("\n"):
assert len(line) <= per, (
f"{name}: 「{line}」{len(line)} 字 > 该块容量 {per} 字"
f"(inner={inner} fs={size})—— 改文案或收窄行长,不许缩字号"
)
# `fit_content` 的节点是收缩包裹的,必须配 `auto` —— 配 fixed-width 会
# 让 jian 把它压到比内容还窄(实测 9 字 24px 的落款被压到 188px),文字
# 于是折出一个「评议」这样的孤字末行。
return text(ids, name, content, size, weight, color, family=family,
line_height=line_height, spacing=spacing, width=width,
growth="auto" if width == "fit_content" else "fixed-width",
align=align)
def mono(name, content, step, weight, color, *, width="fit_content",
align=None, spacing=None):
"""数字与拉丁一律走西文族(spec §3.8 第 5 条):让中文字体去渲染数字,
字宽不齐,跨行跨列的列就会抖。"""
size, line_height, default_spacing = step
return text(ids, name, content, size, weight, color, family=NUM,
line_height=line_height,
spacing=default_spacing if spacing is None else spacing,
width=width, growth="auto" if width == "fit_content" else "fixed-width",
align=align)
# ---------------------------------------------------------------- 结构
def page(name, bg):
"""一页。固定 1920×1080、layout:none —— 版心档的每一块都要落在算得出
的坐标上,交给 flex 排就等于把版心交出去了。"""
node = frame(ids, name, width=W, height=H, layout="none",
fill=solid(bg), clipContent=True)
node["children"] = []
return node
def block(name, x, y, width, children, *, gap=24, padding=None, fill=None,
align="start"):
"""版心里的一块。height 恒为 fit_content —— 写死高度会在文案改一个字时
悄悄裁切。"""
node = frame(ids, name, width=width, height="fit_content",
layout="vertical", gap=gap, alignItems=align,
fill=fill or [])
if padding:
node["padding"] = padding
node["x"], node["y"] = x, y
node["children"] = children
return node
def row(name, x, y, width, children, *, gap=24, padding=None, fill=None,
align="center", justify=None):
node = frame(ids, name, width=width, height="fit_content",
layout="horizontal", gap=gap, alignItems=align,
fill=fill or [])
if padding:
node["padding"] = padding
if justify:
node["justifyContent"] = justify
if x is not None:
node["x"], node["y"] = x, y
node["children"] = children
return node
def jie_rule(x, *, y=RULE_TOP, height=RULE_H, color="$c-jie"):
"""界行。1px 竖线,**贯穿固定高度**。
这是与「卡片分栏」的根本区别:卡片框住内容(框随内容长短变形),界行
标记版面(线的位置与长度是版面的属性,与内容无关)。
"""
node = rect(ids, "界行", width=1, height=height, fill=solid(color))
node["x"], node["y"] = x, y
return node
def hair(x, y, width, *, thickness=1, color="$c-jie"):
"""横向界行 / 表格线。用 rectangle 不用 line —— `line` 在 layout:none 下
吃的是文档绝对坐标,会跑到画布另一个角落去(spec §6.3)。"""
node = rect(ids, "界行 · 横", width=width, height=thickness,
fill=solid(color))
node["x"], node["y"] = x, y
return node
# ---------------------------------------------------------------- 鱼尾
#
# 古籍版心中缝的页码标记。两个等腰三角尖端相对、留一道缝 —— 原创造型,
# 不画鱼、不画任何具象物(spec §2 D9 Strictly avoid 1)。
# 每页复现且位置零漂移,它是这一档跨页恒定的身份锚。
FOLIO_W, FOLIO_TRI_H, FOLIO_GAP = 20, 9, 4
def fish_tail(x, y, *, scale=1):
"""鱼尾本体。`path` 的 d 会被拉伸到节点框,所以坐标写 1:1 即可。"""
w = round(FOLIO_W * scale)
tri = round(FOLIO_TRI_H * scale)
gap = round(FOLIO_GAP * scale)
upper = path(ids, "鱼尾 · 上", f"M0 0 L{w} 0 L{w / 2} {tri} Z",
width=w, height=tri, fill=solid("$c-cinnabar"))
upper["x"], upper["y"] = x, y
lower = path(ids, "鱼尾 · 下", f"M{w / 2} 0 L{w} {tri} L0 {tri} Z",
width=w, height=tri, fill=solid("$c-cinnabar"))
lower["x"], lower["y"] = x, y + tri + gap
return [upper, lower]
def folio_mark(index, total, *, dark=False):
"""地脚居中的鱼尾 + 页码。深页换字色,位置一格不动。"""
color = "$c-xuan-dim" if dark else "$c-ink-faint"
nodes = fish_tail(round((W - FOLIO_W) / 2), 996)
number = mono("页码", f"{index} / {total}", FOLIO, 400, color,
width=200, align="center")
number["x"], number["y"] = round((W - 200) / 2), 1026
nodes.append(number)
return nodes
# ================================================================ 01 封面
def cover():
"""函套。深底、左下重心 —— 整套七页里唯一一个「左下」重心,
与 07 的居中、03 的左偏、05 的居中收束共同构成跨页的重心变化。"""
p = page("01 封面 · 函套", "$c-case")
title = cjk_text("主标题", "汉字排版\n在屏幕上的四次退让", COVER, 700,
"$c-xuan-on", inner=INNER)
p["children"].append(block("主标", SIDE, 430, INNER, [title]))
# 整套封面唯一的朱砂。96×2,短到不可能被读成分隔线,实到不可能被读成
# 4% 不透明度的「假装在设计」(spec §3.7 第 5 条)。
mark = rect(ids, "朱砂短线", width=96, height=2,
fill=solid("$c-cinnabar"))
mark["x"], mark["y"] = SIDE, 685
p["children"].append(mark)
p["children"].append(block("副标", SIDE, 725, INNER, [
cjk_text("副标题", "写给编辑与界面设计者的一份版面评议", BODY, 400,
"$c-xuan-dim", inner=INNER),
]))
p["children"].append(row("落款", SIDE, 880, INNER, [
cjk_text("落款 · 单位", "版面组 · 内部评议", FOLIO, 400,
"$c-xuan-dim", inner=400, width="fit_content"),
mono("落款 · 日期", "2026.08.09", FOLIO, 400, "$c-xuan-dim"),
], gap=32))
p["children"] += folio_mark(1, 7, dark=True)
return p
# ================================================================ 02 篇目
CHAPTERS = [
("01", "版心:先给一页定出可以落墨的地方"),
("02", "界行:分栏是为了缩短行,不是分盒子"),
("03", "行长:三十个字是一道生理线"),
("04", "标点:断行处最见功夫"),
("05", "收束:把四次退让逐项收回"),
]
def contents():
"""篇目。条与条之间只有一根 1px 界行,没有卡片、没有底色块 ——
目录如果需要一张卡片来分组,说明这份材料的结构本身没想清楚。"""
p = page("02 篇目 · 函套", "$c-case")
p["children"].append(block("眉题", SIDE, HEAD, INNER, [
cjk_text("眉题", "篇目", BODY, 500, "$c-xuan-dim", inner=INNER),
]))
items = []
for index, (no, name) in enumerate(CHAPTERS):
if index:
items.append(rect(ids, "界行 · 条间", width="fill_container",
height=1, fill=solid("$c-jie")))
items.append(row(f"篇目 {no}", None, None, "fill_container", [
mono("序号", no, FOLIO, 400, "$c-xuan-dim", width=96),
cjk_text("篇名", name, LEAD, 600, "$c-xuan-on",
inner=INNER - 96 - 32),
], gap=32, padding=[36, 0]))
# 行高 36+54+36 = 126,五条加四根界行 = 634,从 264 起正好收在 898 ——
# 篇目页的高度是算出来的:加一条就会顶出版心,那正是「该拆页」的信号。
p["children"].append(block("篇目列表", SIDE, RULE_TOP, INNER, items, gap=0))
p["children"] += folio_mark(2, 7, dark=True)
return p
# ============================================================ 03 论述页
def discourse():
"""单栏正文 + 界行右侧旁注。62:38 的不对称分栏 —— 50/50 稳定但静止
(spec §3.6)。正文块 960 = 30 字,是行长闸算出来的,不是版心宽度。"""
p = page("03 论述 · 版心", "$c-xuan")
p["children"].append(block("天头批注", SIDE, 56, INNER, [
cjk_text("批注", "版心先于版式;边界不定,谈不上呼吸", NOTE, 400,
"$c-ink-faint", inner=INNER),
]))
p["children"].append(block("页标题", SIDE, HEAD, INNER, [
cjk_text("标题", "版心不是边距,是一页里可以落墨的那块地", TITLE,
700, "$c-ink", inner=INNER),
]))
p["children"].append(block("正文", SIDE, RULE_TOP, BODY_W, [
cjk_text("正文", "\n".join([
"屏幕上的版面把版心简化成了四边等距的内边距,",
"于是每一页都从同一个位置开始,也在同一处结束。",
"古籍的做法相反:天头留给批注,地脚留给页码,",
"两者从来不等高,页面因此有了上下方向。",
"这一档把天头定为 144、地脚定为 108,",
"多出来的那一截,是给投影时被人头遮住的下沿的。",
]), BODY, 400, "$c-ink", inner=BODY_W),
]))
# 正文之下的三条做法。用横向界行与正文隔开,而不是另起一张卡片 ——
# 同一页里的两层内容,靠线与留白分层就够了。
p["children"].append(hair(SIDE, 648, BODY_W))
clauses = []
for index, clause in enumerate([
"天头留给批注,地脚留给页码与鱼尾",
"正文块收窄到三十字,宁可分栏也不放宽",
"界行的长度由版面定,与文字多少无关",
]):
clauses.append(row(f"做法 {index + 1}", None, None, "fill_container", [
mono("序号", f"0{index + 1}", FOLIO, 400, "$c-ink-faint", width=56),
cjk_text("做法", clause, BODY, 400, "$c-ink",
inner=BODY_W - 56 - 24),
], gap=24, padding=[14, 0]))
p["children"].append(block("三条做法", SIDE, 672, BODY_W, clauses, gap=0))
p["children"].append(block("旁注", 1200, RULE_TOP, 600, [
cjk_text("旁注", "\n".join([
"版心之外的留白不是废纸。",
"天头承批注,地脚承页码,",
"两者各有职务在身,",
"所以不必、也不该等高。",
]), NOTE, 400, "$c-ink-soft", inner=600),
]))
p["children"].append(jie_rule(1140))
p["children"] += folio_mark(3, 7)
return p
# ============================================================ 04 双栏对读
def dialogue():
"""其说 / 我见。两栏等宽、中缝一根界行 —— 这一页是「界行」这个母题的
正面论证:把同一件事的两种说法并排,靠线分,不靠卡片分。"""
p = page("04 双栏对读", "$c-xuan")
p["children"].append(block("页标题", SIDE, HEAD, INNER, [
cjk_text("标题", "分栏是为了把行缩短,不是把内容装进两个盒子",
TITLE, 700, "$c-ink", inner=INNER),
]))
col_w = 808
p["children"].append(block("左栏 · 其说", SIDE, RULE_TOP, col_w, [
cjk_text("栏题", "其说", LEAD, 600, "$c-ink-soft", inner=col_w),
cjk_text("左栏正文", "\n".join([
"通行的做法是给每一栏加一个",
"圆角卡片,再用底色把两边分开。",
"分是分开了,读者读到的却是",
"两张卡片,而不是一篇文章的",
"两个部分。",
]), BODY, 400, "$c-ink", inner=col_w),
cjk_text("左栏续", "\n".join([
"卡片还有一个副作用:它必须有内边距,",
"于是同一块版心里,能排的字更少,",
"行却没有因此变短。",
]), BODY, 400, "$c-ink-soft", inner=col_w),
], gap=32))
# 右栏首句压朱砂淡底 —— 本页唯一一次强调。第二次出现就不再是强调
# (spec §3.6),所以整页别处一点朱砂都没有。
wash = block("首句", 0, 0, "fill_container", [
cjk_text("首句", "界行只标记分栏,不圈占内容。", BODY, 500,
"$c-ink", inner=col_w - 48),
], padding=[24, 24], fill=solid("$c-cinnabar-wash"))
wash.pop("x"), wash.pop("y")
p["children"].append(block("右栏 · 我见", SIDE + col_w + 64, RULE_TOP,
col_w, [
cjk_text("栏题", "我见", LEAD, 600, "$c-ink-soft", inner=col_w),
wash,
cjk_text("右栏正文", "\n".join([
"线画在版面上,卡片扣在内容上。",
"界行贯穿整个版心,不随文字长短",
"伸缩;它分的是版面,不是归属。",
]), BODY, 400, "$c-ink", inner=col_w),
cjk_text("右栏续", "\n".join([
"把分栏交给线之后,栏宽就能完全由",
"行长决定:三十字一行,多出来的宽度",
"留给留白,而不是留给边框。",
]), BODY, 400, "$c-ink-soft", inner=col_w),
], gap=32))
p["children"].append(jie_rule(960))
p["children"] += folio_mark(4, 7)
return p
# ============================================================== 05 引文页
def quotation():
"""引文居中,左右各一根界行框住。整套唯一一页居中重心的亮页 ——
「不许页页居中」的另一面是「也不许一页都不居中」,节奏靠对比产生。"""
p = page("05 引文", "$c-xuan")
p["children"].append(block("天头批注", SIDE, 56, INNER, [
cjk_text("批注", "回行的距离,是版面替眼睛付的成本", NOTE, 400,
"$c-ink-faint", inner=INNER),
]))
p["children"].append(block("页标题", SIDE, HEAD, INNER, [
cjk_text("标题", "一行超过三十个汉字,眼睛就会在回行时丢位置",
TITLE, 700, "$c-ink", inner=INNER),
]))
# 引文块落在版心的下二分之一而不是紧贴标题:这一页只有一个读点,
# 上下留白大致相等时它才读作「被托住」,而不是「掉在标题下面」。
quote_w, quote_pad = 1080, 64
p["children"].append(block("引文块", round((W - quote_w) / 2), 372,
quote_w, [
cjk_text("引文", "\n".join([
"行的长度决定读者每次回行要走多远。",
"走得太远,眼睛会落到上一行或下一行,",
"于是要重读;重读不是理解慢,是版面失职。",
]), QUOTE, 400, "$c-ink", inner=quote_w - quote_pad * 2),
], padding=[quote_pad, quote_pad], fill=solid("$c-xuan-deep")))
p["children"].append(block("出处", round((W - quote_w) / 2), 732,
quote_w, [
cjk_text("出处", "——《版面与阅读》第二章 · 行长与回行", NOTE, 400,
"$c-ink-soft", inner=quote_w, align="right"),
]))
p["children"].append(jie_rule(360))
p["children"].append(jie_rule(1560))
p["children"] += folio_mark(5, 7)
return p
# ============================================================== 06 表列页
# 列宽写死(spec §6.10):渲染栈没有 tabular 数字,靠 fit_content 指望列
# 自己对齐,一行长一点整张表就抖。
TABLE_COLS = (280, 388, 376, 532)
TABLE_PAD = 16
TABLE_GAP = 24 # ≥ SIBLING_JAM_GAP(3) 的真 gap,不靠 padding 撑
TABLE_HEAD = ("现象", "当前引擎行为", "后果", "我们的做法")
TABLE_ROWS = [
("行首标点", "逐字断行,不避头尾", "「。」被甩到行首", "标题写死断点,正文控字数"),
("孤字末行", "末行只剩一两个字", "版面出现残行", "收窄栏宽,或改写文案"),
("中英混排", "两侧不自动加间隙", "数字与汉字粘连", "文案里补半角空格"),
("数值对齐", "没有等宽数字", "列与列之间发抖", "数字走西文族,列宽写死"),
("省略号截断", "直接截去后半句", "读者读到半截话", "不截断,改写或换页型"),
]
def table_row(name, cells, *, weight, color, fill=None):
kids = []
for width, content in zip(TABLE_COLS, cells):
kids.append(cjk_text(f"{name} · 格", content, CELL, weight, color,
inner=width, width=width))
return row(name, None, None, "fill_container", kids, gap=TABLE_GAP,
padding=[20, TABLE_PAD], fill=fill, align="start")
def table_page():
"""表格。表头 2px 墨线、行间 1px 界行、**末行无线**、斑马走宣深。
整框边线一根没有 —— 表格的结构应该由横线与留白给出,不由一个框给出。"""
p = page("06 表列", "$c-xuan")
p["children"].append(block("天头批注", SIDE, 56, INNER, [
cjk_text("批注", "避头尾在引擎里缺席,只能由文案补上", NOTE, 400,
"$c-ink-faint", inner=INNER),
]))
p["children"].append(block("页标题", SIDE, HEAD, INNER, [
cjk_text("标题", "断行处最见功夫,而排版引擎并不替我们避头尾",
TITLE, 700, "$c-ink", inner=INNER),
]))
rows = [table_row("表头", TABLE_HEAD, weight=600, color="$c-ink"),
rect(ids, "表头线", width="fill_container", height=2,
fill=solid("$c-ink"))]
for index, cells in enumerate(TABLE_ROWS):
if index:
rows.append(rect(ids, "界行 · 行间", width="fill_container",
height=1, fill=solid("$c-jie")))
rows.append(table_row(f"表行 {index + 1}", cells, weight=400,
color="$c-ink",
fill=solid("$c-xuan-deep") if index % 2 else None))
p["children"].append(block("表格", SIDE, RULE_TOP, INNER, rows, gap=0))
p["children"].append(block("来源", SIDE, 848, INNER, [
cjk_text("来源", "来源:版面组对内部三十份中文文档的抽样,"
"2026 年 7 月", FOLIO, 400, "$c-ink-faint", inner=INNER),
]))
p["children"] += folio_mark(6, 7)
return p
# ============================================================== 07 收束
def closing():
"""函套收尾。放大的鱼尾在收束句之上 —— 页尾那枚小鱼尾照旧在地脚,
位置零漂移;放大的这一枚是构图,不是页码。"""
p = page("07 收束 · 函套", "$c-case")
p["children"] += fish_tail(round((W - FOLIO_W * 4) / 2), 300, scale=4)
p["children"].append(block("收束句", SIDE, 470, INNER, [
cjk_text("收束", "把四次退让逐项收回来,\n从一行三十字开始。",
CLOSE, 700, "$c-xuan-on", inner=INNER, align="center"),
]))
p["children"].append(row("落款", SIDE, 700, INNER, [
cjk_text("落款 · 单位", "版面组 · 内部评议", FOLIO, 400,
"$c-xuan-dim", inner=400, width="fit_content"),
mono("落款 · 日期", "2026.08.09", FOLIO, 400, "$c-xuan-dim"),
], gap=32, justify="center"))
p["children"] += folio_mark(7, 7, dark=True)
return p
# ---------------------------------------------------------------- 板位
# spec §6.15:3 板一行;行距 1440 而不是 1200 —— 画布在帧上方以固定屏幕
# 偏移画帧名,行距不够时第二行的帧名会压到上一行的板上。
BOARD_X, BOARD_Y, PER_ROW = 2040, 1440, 3
def build():
boards = [cover(), contents(), discourse(), dialogue(), quotation(),
table_page(), closing()]
for index, board in enumerate(boards):
board["x"] = (index % PER_ROW) * BOARD_X
board["y"] = (index // PER_ROW) * BOARD_Y
return boards
# 对比度(WCAG 相对亮度比,spec §2 D9 色板表实算值):
# c-ink on c-xuan 13.46
# c-ink-soft on c-xuan 5.99
# c-ink-faint on c-xuan 3.72 ← 仅承载 ≥24px 的批注/页码/来源
# c-ink on c-xuan-deep 11.73(引文块、斑马行)
# c-ink on c-cinnabar-wash 11.41(04 页首句)
# c-xuan-on on c-case 14.94
# c-xuan-dim on c-case 8.14 ← 深页次级文字与页码
# c-cinnabar on c-xuan 5.19(本档朱砂只作图形与短线,不承载正文)
# c-jie 只画线不承载文字,按 spec §4.4 第 2 条豁免(非文字装饰线)。
#
# 字号地板(spec §3.4):最小 24(批注/页码/来源,全部 ≥24 的合法例外区),
# 最大 96 ≥ 60 的层级线,且 96 / 32 = 3.0 ≥ 2.5 倍的层级下限。
# 单页字阶档数:01=3 / 02=3 / 03=4 / 04=4 / 05=4 / 06=4 / 07=2,均 ≤4。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "版心界行 · 中文原生排版档")

View file

@ -0,0 +1,640 @@
#!/usr/bin/env python3
"""dossier-linen-deck.op — 卷宗亚麻 · 高密度文档备忘档(1920×1080 · 8 页)
deck 体系 D11。落格 high formality × **high** density × light。
定位:每一页都是一张可独立抽出来读的备忘录纸,不是幻灯片 —— 零 bullet、
编号段落、纯表格、异步阅读优先。
### 这一档要示范的那件事
**高密度 ≠ 缩字号。** 整套的正文密度是 deck 体系里最高的,而字号地板与其
他档完全相同(正文 30 是绝对下限)。密度来自三处,一处都不来自把字缩小:
1. **行长**:正文块收窄到 900px(30px 字号下正好 30 汉字/行),一页能排
下的段落数因此上去了,而每一行仍然好读。
2. **段落结构**:层级只由段号深度表达(§1 / §1.1),零 bullet 符号 ——
符号本身是要占位的,编号不占。
3. **留白分配**:左边距 216 全给装订线,右边距只有 120。不对称让出的那
96px 不是浪费,它是段号的悬挂缩进。
### 锚点与色彩推导(采样 → 收敛 → 论证)
- **采样**:亚麻纹公文封的暖白、档案墨的近黑暖调、装订线绳的锈色。
- **收敛**:底 = 亚麻 oklch(0.950 0.014 82);**唯一有彩色 = 锈色 H35
C0.090**,且只走线与页码;中性走暖灰序列(H60,与底同暖向)。
- **论证一**:这是体系里唯一的单色档。备忘录的说服力来自结构不来自视
觉;任何第二支颜色都会诱导读者去找「重点色」,而这一档的重点应该在句
子里。
- **论证二**:锈色 chroma 只到 0.090。拉到 0.13+ 装订线就从「绳」变成
「红线标注」,语义完全改变。
- **论证三**:底色 chroma 0.014 是亚麻纸的黄 —— 比奶油纸更灰、比新闻纸
更暖,要读作「公文用纸」而不是「手账内页」。
### 招牌母题(识别度载体,每页复现且位置零漂移)
1. **装订线 binding-rail** —— 距左缘 120px 一条贯穿全高的 2px 锈色竖
线,线上等距三处 12×12 的方孔(亚麻底 + 1px 细线描边)。
2. **编号段落 numbered-clause** —— mono 段号挂在装订线**左侧**的悬挂缩
进里(右对齐至装订线 −24px),段落正文从装订线 +96px 起。
3. **骑缝页标 folio-tab** —— 右上角 `p. N / 8` + 其下一条 48×1 细线;左
上角对称位置放文档代号(letterSpacing +2)。
### 负约束(Strictly avoid,逐条对应 spec §2 D11)
- **全篇零 bullet 符号**。只有编号段落、完整段落与带线表格。
- **数据只走表格,绝不出图表** —— 图表在这一档是「演示」,而这一档不是
演示。
- 不用第二支有彩色;锈色之外一律中性。
- 不用圆角、阴影、渐变、图标、照片、卡片。
- 装订线与正文起点每页同一个 x,零漂移。
- 段号最多三级。
- 高密度不许触碰字号地板:正文 30 是绝对下限,装不下就删段落或加页。
- 页标写实际总页数(`p. 3 / 8`),不写单侧页码。
- 不写「如上页所述」这类依赖顺序的表述 —— 每页能被单独抽出来读。
- 表格不用彩色表头、不用整框边线;只用上下横线与斑马行。
### 工程约束(spec §6,逐条踩过的坑)
- **字体走保底层**:模板里只写 `Noto Sans SC`(中文)与 `Inter`(数字与
拉丁)。spec 的首选字体(思源宋体 / 霞鹜文楷 / IBM Plex Mono)不在渲
染字体包内,写了会静默回退到 Roboto,并触发「量 A 字体画 B 字体」的裁
切与偏心。
- **数值列宽写死**:Inter 在当前栈里不保证等宽数字,表格列宽一律固定
px,不靠 fit_content 指望数字自己对齐。
- **不用 line 节点、不用 dashPattern**:所有线(装订线、表格横线、签署
位)都是 rectangle。
- **相邻单元格留 4px 真格线**(`SIBLING_JAM_GAP = 3`):行底色从缝隙透
出来,既是几何合规也是这一档「不画竖格线」的实现方式。
- **顶层一次性给出整棵 8 帧树**,每帧显式写 x/y(板位 2040 × 1440)。
字号与行高(§3.4/§3.5 硬地板:任意文本 ≥20,注释 ≥22,全 deck 最大 ≥60,
最大字号 ≥ 2.5 × 正文):
文档标题 76 / 1.10 / −2 · 节标题 44 / 1.25 · 正文 30 / 1.80
表格 28 / 1.45 · 段号 mono 26 · 页标与脚注 mono 24 / 1.5
76 = 2.53 × 30,正好过 §3.4 的层级比;spec §2 的 72 只到 2.40 倍,这里取
76 是为了同时满足两条契约(差额写在这里,不是随手改的)。
对比度(WCAG 相对亮度比,实测见文件末尾):承载正文的最低一对是 4.29。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import Ids, color_vars, frame, rect, solid, text, write_doc
ids = Ids()
VARS = color_vars({
"c-linen": "#F3EEE4", # 页面底 · 亚麻纸
"c-band": "#EFE9DE", # 表格斑马行
"c-deep": "#E6DFD3", # 引文块 / 决策块
"c-ink": "#201C19", # 主文字 14.63 : linen
"c-ink-soft": "#56524E", # 次级文字 6.70
"c-ink-faint": "#746F6B", # 页标 / 段号 4.29
"c-rule": "#C0BDB8", # 细线(非文字)
"c-thread": "#864737", # 唯一有彩:装订线 / 页码 / 段号 6.11
})
CJK = "Noto Sans SC"
NUM = "Inter"
W, H = 1920, 1080
PAGES = 8
DOC_CODE = "OPS-2026-041"
# 版式语法。左边距 216 = 装订线 120 + 正文让出的 96,右边距只有 120 ——
# 这个不对称是本档最强的身份信号。
PAD_TOP, PAD_BOTTOM, PAD_RIGHT = 96, 96, 120
RAIL_X = 120 # 装订线的 x,每页零漂移
NUM_W = 96 # 悬挂段号列:右缘正好落在装订线左侧 24px
NUM_GAP = 120 # 段号列右缘 → 正文起点(= RAIL_X + 96 = 216)
BODY_W = W - PAD_RIGHT - NUM_W - NUM_GAP # 1584
BODY_X = NUM_W + NUM_GAP # 216
# 正文块的行长闸。CJK 正文 ≤30 汉字/行(§3.8-3):30px 字号下就是 900px。
# 内容宽有 1584,但「容器有多宽就排多宽」正是长段落最常见的失败。
TEXT_W = 900
FS_DOC, FS_SEC, FS_BODY = 76, 44, 30
# 段号、表头、页标、脚注**共用 26 这一档**。它们本来可以分成 26 / 24 两
# 档,但表格页上已经有 44 / 30 / 28 三档,再分就是单页 5 档,越过 §3.4 的
# 「单页字阶档数 ≤4」。26 仍落在注释档 24–26 的目标区间里。
FS_TABLE, FS_CLAUSE, FS_META = 28, 26, 26
LH_DISPLAY, LH_SEC, LH_BODY, LH_TABLE, LH_META = 1.10, 1.25, 1.80, 1.45, 1.50
GRID = 4 # 单元格之间的真格线;≥ SIBLING_JAM_GAP(3)
# ------------------------------------------------------------------ 骨架
def col(name, children, *, gap=0, width="fill_container", fill=None, **props):
node = frame(ids, name, width=width, height="fit_content",
layout="vertical", gap=gap, fill=fill or [], **props)
node["children"] = children
return node
def row(name, children, *, gap=0, align="start", width="fill_container",
fill=None, **props):
node = frame(ids, name, width=width, height="fit_content",
layout="horizontal", gap=gap, alignItems=align,
fill=fill or [], **props)
node["children"] = children
return node
def rule(width, thickness=1, color="$c-rule"):
"""一条横线。**只用 rectangle**:`line` 在 layout:none 下吃的是文档绝对
坐标,会跑到画布的另一个角落去。"""
return rect(ids, "细线", width=width, height=thickness, fill=solid(color))
# 拉丁与数字在 CJK 字体里约占半个字身。取 0.6 而不是 0.5 是留一档余量:
# 这个系数只决定断言的松紧,宁可早报一行也不要漏一行。
LATIN_EM = 0.6
def line_em(line):
"""一行文字的宽度,单位是「字」。CJK 记 1,其余记 LATIN_EM。"""
return sum(1.0 if ch > "⿿" else LATIN_EM for ch in line)
def fits(content, size, width):
"""产稿期的折行断言:改文案当场报错,不必跑完闸再回来猜哪一行折了。
量的是**渲染真实宽度**(拉丁半宽),不是 cjkcheck 的 1em 保守模型 ——
后者会把「上季度共 1342 条上报……」这种混排句判成折行,而它实际排得
下。两者是互补的:这里守「不许发生自动折行」,闸守「万一折了会不会出
孤字末行 / 行首标点」。
断点一律写死在文案里,所以每一硬行都要自己排得下。
"""
if not isinstance(width, (int, float)) or not isinstance(content, str):
return
if not content.strip():
return
per = width / size
for line in content.split("\n"):
if line_em(line) > per:
raise ValueError(
f"断行超容量:{line!r} 折合 {line_em(line):.1f} 字 > 每行 "
f"{per:.1f} 字(宽 {width} · 字号 {size})。按 §3.1 缩短文"
f"案、换页型或拆页,不缩字号。")
def body(content, *, size=FS_BODY, weight=400, color="$c-ink",
width=TEXT_W, lh=LH_BODY, family=CJK, spacing=0, align=None,
name="正文"):
"""一段正文。断点一律写死在文案里 —— 当前 wrap_text 对 CJK 是逐字符断
行、没有标点避头尾,交给自动折行会在「。」前面断开。"""
fits(content, size, width)
return text(ids, name, content, size, weight, color, family=family,
line_height=lh, width=width, growth="fixed-width",
align=align, spacing=spacing)
def clause(number, children):
"""一个编号段:mono 段号悬挂在装订线左侧,正文从装订线 +96 起。
段号的行盒高度对齐正文首行(26 × lh = 30 × 1.8),否则段号会比它领的
那一行高出几个像素,八页看下来是可见的抖动。
"""
num = text(ids, "段号", number, FS_CLAUSE, 500, "$c-thread", family=NUM,
line_height=round(FS_BODY * LH_BODY / FS_CLAUSE, 3),
width=NUM_W, growth="fixed-width", align="right")
return row("编号段", [num, col("段落", children, gap=18)], gap=NUM_GAP)
def section(number, title, children, *, gap=28):
"""节标题 + 其下的内容。节号同样走悬挂缩进,层级只由段号深度表达。
`children` 里的编号段自己带悬挂列,**这里不再统一缩进** —— 缩两次的结
果是段号被推进正文里,悬挂缩进就没有了。不带段号的块(表格、引文块、
决策块)由调用方用 `indent()` 显式让出 216。
"""
num = text(ids, "节号", number, FS_CLAUSE, 500, "$c-thread", family=NUM,
line_height=round(FS_SEC * LH_SEC / FS_CLAUSE, 3),
width=NUM_W, growth="fixed-width", align="right")
fits(title, FS_SEC, BODY_W)
head = text(ids, "节标题", title, FS_SEC, 600, "$c-ink", family=CJK,
line_height=LH_SEC, width=BODY_W, growth="fixed-width")
return col("节", [
row("节标题行", [num, head], gap=NUM_GAP),
col("节内容", children, gap=gap),
], gap=gap)
def indent(children, *, gap=0, **props):
"""没有段号的块。用 padding 让出 216,不塞一个空的占位节点进来。"""
return col("正文块", children, gap=gap, padding=[0, 0, 0, BODY_X], **props)
def meta_line(label, value, *, value_family=NUM):
"""收件人 / 日期 / 密级这类元信息行。中文标签走 CJK、值走西文族 ——
让中文字体去渲染数字会字宽不齐,列就抖了。"""
return row("元信息行", [
text(ids, "元信息标签", label, FS_META, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
text(ids, "元信息值", value, FS_META, 500, "$c-ink-soft",
family=value_family, width="fit_content", growth="auto",
line_height=LH_META),
], gap=16, align="start")
# ------------------------------------------------------------------ 骑缝页标
def folio(index):
"""右上角页标 + 左上角文档代号。两者共同构成「这是一份文件的第 N 页」。"""
code = text(ids, "文档代号", DOC_CODE, FS_META, 500, "$c-ink-faint",
family=NUM, width="fit_content", growth="auto",
line_height=LH_META, spacing=2)
page_no = text(ids, "页标", f"p. {index} / {PAGES}", FS_META, 500,
"$c-thread", family=NUM, width="fit_content",
growth="auto", line_height=LH_META)
tab = col("骑缝页标", [page_no, rule(48)], gap=10, width="fit_content",
alignItems="end")
# 页眉与正文同起点(装订线 +96),否则文档代号会一路顶到画布左缘去。
return indent([
row("页眉", [code, tab], gap=48, width="fill_container",
justifyContent="space_between"),
])
# ------------------------------------------------------------------ 装订线
def binding_rail():
"""装订线 + 三处装订孔。孔写在数组前面 —— layout:none 的 z-order 是
children[0] 在最上,写反了孔就被线盖住。"""
holes = []
for y in (270, 540, 810):
hole = rect(ids, "装订孔", width=12, height=12,
fill=solid("$c-linen"),
stroke={"thickness": 1, "fill": solid("$c-rule")})
hole["x"], hole["y"] = RAIL_X - 5, y - 6
holes.append(hole)
thread = rect(ids, "装订线", width=2, height=H, fill=solid("$c-thread"))
thread["x"], thread["y"] = RAIL_X, 0
return [*holes, thread]
def page(name, index, children, *, gap=40, justify="start"):
"""一页备忘录纸。固定 1920×1080,绝不 fit_content。"""
content = frame(ids, f"{name} · 正文", width="fill_container",
height="fill_container", layout="vertical",
padding=[PAD_TOP, PAD_RIGHT, PAD_BOTTOM, 0], gap=gap,
justifyContent=justify, fill=[])
content["children"] = [folio(index), *children]
ornament = frame(ids, f"{name} · 装订", width="fill_container",
height="fill_container", layout="none", fill=[])
ornament["children"] = binding_rail()
shell = frame(ids, name, width=W, height=H, layout="none",
fill=solid("$c-linen"), clipContent=True)
shell["children"] = [content, ornament]
return shell
# ------------------------------------------------------------------ 带线表格
def cell(content, width_px, *, weight=400, color="$c-ink", family=CJK,
size=FS_TABLE, align=None):
"""一个单元格。底色留给行,单元格自己透明 —— 这一档不画竖格线,格与
格之间靠 4px 的真缝隙分开。
那 4px 只负责**分格**;两列文字之间真正的呼吸量是两侧各 24 的内边距
(文字到文字 52px),不是靠 4px 贴着 SIBLING_JAM_GAP 的下限过关。
"""
fits(content, size, width_px - 48)
box = frame(ids, "单元格", width=width_px, height="fit_content",
layout="vertical", padding=[16, 24], gap=0, fill=[])
box["children"] = [
text(ids, "单元格文字", content, size, weight, color, family=family,
line_height=LH_TABLE, width="fill_container",
growth="fixed-width", align=align),
]
return box
def table(columns, header, rows, *, emphasis=None):
"""带线表格:只有上下横线与斑马行,没有彩色表头、没有整框边线。
`columns` 是 (宽度, 对齐, 字族) 三元组,宽度**写死** —— 渲染栈没有
tabular 数字,指望 fit_content 让数字自己对齐必然抖。
`emphasis` 给最后一行(推荐行 / 合计行)加粗并在其上加一条 2px 锈线。
"""
assert sum(w for w, _, _ in columns) + GRID * (len(columns) - 1) == BODY_W
def line_row(cells, fill=None):
return row("表行", cells, gap=GRID, align="stretch", fill=fill)
head_cells = [
cell(label, w, weight=600, color="$c-ink-soft", family=CJK,
size=FS_CLAUSE, align=align)
for label, (w, align, _) in zip(header, columns)
]
out = [rule(BODY_W, 2), line_row(head_cells), rule(BODY_W, 1)]
for index, values in enumerate(rows):
last = emphasis and index == len(rows) - 1
if last:
out.append(rule(BODY_W, 2, "$c-thread"))
cells = [
cell(value, w, weight=600 if last else 400, family=family,
align=align)
for value, (w, align, family) in zip(values, columns)
]
band = None if last else (solid("$c-band") if index % 2 == 0 else None)
out.append(line_row(cells, fill=band))
out.append(rule(BODY_W, 1))
return col("表格", out, gap=0)
# ------------------------------------------------------------------ 01 首页
SUMMARY = ("巡检是我们唯一能在事故发生前拿到的一手信息。\n"
"过去两年它以人工抽查的方式运行,覆盖到的门店不足四成,\n"
"上报的问题在纸质表单与工作群之间流转,闭环时长无从统计。\n"
"本备忘给出三个方案的取舍、所需投入,\n"
"以及三项需要在本月内批复的事项。")
def cover():
title = body("把门店巡检从抽查\n改为全量记录", size=FS_DOC, weight=600,
lh=LH_DISPLAY, spacing=-2, width=BODY_W, name="文档标题")
return page("01 文件首页", 1, [
indent([
# 起首线在标题**上方**:它是信笺抬头与正文的分界,不是「每个标
# 题下面一条横线」那条 AI 指纹。
rule(BODY_W, 2, "$c-thread"),
title,
body(SUMMARY, name="摘要"),
col("签发信息", [
meta_line("收件人", "运营中心 · 门店运营组 · 信息技术部",
value_family=CJK),
meta_line("签发日期", "2026-08-09"),
meta_line("密级", "内部 · 限项目组", value_family=CJK),
], gap=12),
], gap=44),
], gap=40, justify="space_between")
# ------------------------------------------------------------------ 02 背景
def background():
return page("02 背景", 2, [
section("§1", "背景与问题", [
clause("§1.1", [body(
"人工抽查按片区轮换,每家门店平均 47 天才被巡到一次。\n"
"到店的检查项由巡检员当场手写,回办公室再录入系统,\n"
"两个环节之间平均隔了 1.8 天,照片与表单常常对不上号。\n"
"抽查本身没有错,错的是把抽查的结果当成了全量的结论。")]),
clause("§1.2", [body(
"问题上报走工作群,群里同时还有排班、报修与日常通知。\n"
"信息一旦被后面的消息顶下去,就等于没有发生过。\n"
"上季度共 1342 条上报,能追溯到闭环记录的只有 611 条。")]),
clause("§1.3", [body(
"各区域对同一个检查项的判定尺度并不一致:\n"
"华东把冷链温度超标记为一般问题,华南记为严重问题,\n"
"汇总到集团时,两者被加在了同一个数字里。")]),
], gap=32),
], gap=48)
# ------------------------------------------------------------------ 03 数据
REGION_COLS = [
(388, None, CJK), # 片区
(300, "right", NUM), # 月均巡检次数
(280, "right", NUM), # 问题上报条数
(280, "right", NUM), # 整改闭环率
(320, "right", NUM), # 平均闭环时长
]
REGION_HEAD = ["片区", "月均巡检次数", "上报条数", "闭环率(%)",
"闭环时长(天)"]
REGION_ROWS = [
["华东", "412", "386", "46", "5.2"],
["华南", "358", "341", "51", "4.6"],
["华北", "296", "302", "39", "6.8"],
["西南", "187", "154", "44", "5.9"],
["东北", "121", "98", "33", "8.1"],
["全国", "1374", "1281", "44", "5.9"],
]
def facts():
return page("03 现状数据", 3, [
section("§2", "现状:五个片区的四项读数", [
indent([
table(REGION_COLS, REGION_HEAD, REGION_ROWS, emphasis=True),
body("数据来源:巡检系统 2026 年第二季度导出;判定口径见 "
"§1.3。闭环率按「有整改记录且经复核」计,与月报一致。",
size=FS_META, color="$c-ink-faint", lh=LH_META,
width=BODY_W, name="来源行"),
], gap=24),
clause("§2.1", [body(
"巡检次数与上报条数几乎同步下降,闭环率却没有跟着走:\n"
"东北片区巡到的次数最少,闭环时长反而最长 8.1 天。\n"
"这说明瓶颈不在巡得够不够勤,而在巡完之后没人接得住。")]),
], gap=32),
], gap=40)
# ------------------------------------------------------------------ 04 分析
def analysis():
quote = col("引文块", [
body("我们不缺巡检,缺的是能被复算的巡检。",
size=FS_BODY, weight=600, width=1100, lh=1.5, name="引文"),
body("—— 华东区运营负责人,6 月复盘会记录", size=FS_META,
color="$c-ink-soft", lh=LH_META, width=1100, name="引文出处"),
], gap=14, fill=solid("$c-deep"), padding=[32, 40], width=1200)
return page("04 分析", 4, [
section("§3", "为什么抽查救不回来", [
clause("§3.1", [body(
"抽查的样本由排班决定,而排班避开了最难去的那些门店。\n"
"越是偏远、越是人手紧的门店,被巡到的间隔越长,\n"
"于是问题最多的地方,恰好是我们了解得最少的地方。\n"
"这不是执行不力,是抽样方法自带的偏差。")]),
clause("§3.2", [body(
"闭环率停在 44%,不是因为没人整改,\n"
"而是因为整改完成后没有一个必须回填的动作。\n"
"没有回填,就没有记录;没有记录,复算就无从谈起。")]),
indent([quote]),
], gap=28),
], gap=40)
# ------------------------------------------------------------------ 05 方案
PLAN_COLS = [
(384, None, CJK),
(396, None, CJK),
(396, None, CJK),
(396, None, CJK),
]
PLAN_HEAD = ["评估维度", "甲 · 扩编巡检员", "乙 · 外包第三方", "丙 · 自检加抽核"]
PLAN_ROWS = [
["覆盖率", "提升有限", "可达九成", "全量覆盖"],
["单店月成本", "高", "中", "低"],
["落地周期", "三个月", "六周", "十周"],
["数据一致性", "依赖个人经验", "依赖合同条款", "由系统约束"],
["主要风险", "招聘周期偏长", "数据不在自己手里", "需要门店配合"],
["结论", "不建议", "备选", "推荐"],
]
def options():
return page("05 方案对比", 5, [
section("§4", "三个方案,按同一组维度逐行回答", [
indent([
table(PLAN_COLS, PLAN_HEAD, PLAN_ROWS, emphasis=True),
body("推荐方案丙。它是三者里唯一让「记录」成为流程副产品的"
"一个,其余两个都还要靠人记得去做。",
size=FS_META, color="$c-ink-soft", lh=LH_META,
width=BODY_W, name="推荐说明"),
], gap=24),
clause("§4.1", [body(
"成本按 240 家门店试算:甲需增编 11 人,年支出约 190 万;\n"
"乙的报价随门店数线性上涨,丙在第二年起低于两者的一半。")]),
], gap=32),
], gap=40)
# ------------------------------------------------------------------ 06 叙述
def prospect():
signature = col("署名引语", [
body("把巡检做成一件不需要提醒的事,\n是这次改造真正的目标。",
size=FS_BODY, weight=500, color="$c-ink-soft", width=1100,
lh=1.6, name="引语"),
body("—— 项目组,2026 年 8 月", size=FS_META, color="$c-ink-faint",
lh=LH_META, width=1100, name="引语署名"),
], gap=12, padding=[0, 0, 0, 36])
bar = rect(ids, "引语竖线", width=2, height=132, fill=solid("$c-thread"))
return page("06 逆向叙述", 6, [
section("§5", "若此事已成", [
clause("§5.1", [body(
"到明年三月,每家门店在每个自然周都会留下完整的巡检记录。\n"
"记录不再依赖谁记得拍照,而是由流程本身产生:\n"
"检查项没有勾完,当天的巡检就不算结束。\n"
"区域经理在周一早晨看到的不是一份汇总表,\n"
"而是上周所有未闭环问题按门店排开的清单。\n"
"我们不再讨论覆盖率,因为覆盖率恒等于百分之百;\n"
"要讨论的是闭环时长,以及它为什么在某些门店偏长。")]),
indent([row("署名行", [bar, signature], gap=0, align="start")]),
], gap=32),
], gap=40, justify="center")
# ------------------------------------------------------------------ 07 疑问
QA = [
("问:全量记录会不会把巡检变成打卡?",
"不会。检查项从 42 项压到 18 项,每项都要有照片或读数支撑,\n"
"勾选式的应付会在抽核环节被直接退回。"),
("问:门店自检的结果可信吗?",
"抽核比例定在 15%,由区域经理随机指定,结果与自检并列存档。\n"
"连续两次抽核不一致的门店,恢复为人工巡检。"),
("问:需要门店多花多少时间?",
"单次自检 12 分钟,比现行的纸质表单少 4 分钟。\n"
"时间省在录入上,不省在检查上。"),
("问:这套记录会被用来考核门店吗?",
"前两个季度只用于发现问题,不进考核。\n"
"考核口径需要另行讨论,不在本备忘的范围内。"),
]
def questions():
blocks = []
for index, (question, answer) in enumerate(QA):
if index:
blocks.append(indent([rule(BODY_W, 1)]))
blocks.append(clause(f"§6.{index + 1}", [
body(question, weight=600, lh=1.4, name="问"),
body(answer, color="$c-ink-soft", lh=1.65, name="答"),
]))
return page("07 疑问", 7, [
section("§6", "已经被问到的四个问题", blocks, gap=18),
], gap=28)
# ------------------------------------------------------------------ 08 决议
DECISIONS = [
("批准方案丙,在华东与华南两区先行试点。",
"运营中心 · 周敏", "2026-08-20"),
("批准巡检系统改造预算 86 万元,分两期拨付。",
"财务部 · 陈立", "2026-08-27"),
("批准抽核比例与退回规则写入门店运营手册。",
"门店运营组 · 李哲", "2026-09-10"),
]
def resolution():
items = []
for index, (what, who, due) in enumerate(DECISIONS):
if index:
items.append(rule(1200 - 80, 1))
items.append(col("待批准事项", [
row("事项行", [
text(ids, "事项序号", f"{index + 1}", FS_CLAUSE, 500,
"$c-thread", family=NUM, width=40, growth="fixed-width",
line_height=round(FS_BODY * 1.5 / FS_CLAUSE, 3)),
body(what, width=1000, lh=1.5, name="事项"),
], gap=16),
# 中文标签走 CJK、日期走西文族,各自一个节点:把
# 「期限 2026-08-20」写成一个 Inter 节点,中文会掉进回退字体,
# 同一行里出现两种字面(实测可见)。
row("责任行", [
text(ids, "决策人", f"决策人 {who}", FS_META, 400,
"$c-ink-soft", family=CJK, width="fit_content",
growth="auto", line_height=LH_META),
text(ids, "期限标签", "期限", FS_META, 400, "$c-ink-soft",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
text(ids, "期限日期", due, FS_META, 500, "$c-ink-soft",
family=NUM, width="fit_content", growth="auto",
line_height=LH_META),
], gap=12, padding=[0, 0, 0, 56]),
], gap=10))
block = col("决策块", items, gap=20, fill=solid("$c-deep"),
padding=[36, 40], width=1200)
sign = col("签署位", [
rule(480, 1),
row("签署标签", [
text(ids, "签署", "批准签署", FS_META, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
text(ids, "签署日期", "日期", FS_META, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
], gap=280),
], gap=12, width=480)
return page("08 决议", 8, [
section("§7", "待批准事项", [
indent([block, sign], gap=32),
# 末页是**陈述**不是信息:把开头立起来的那件事在这里收掉,而不
# 是停在一张待办清单上。
clause("§7.1", [body(
"三项批复完成后,本备忘归档为定稿并抄送三个部门;\n"
"未获批复的部分按原流程继续执行,下一次复核安排在 11 月。")]),
], gap=32),
], gap=36)
# ------------------------------------------------------------------ 板位
# 3 板一行。行距 1440(而不是 1200):画布在帧上方以固定屏幕偏移画帧名,
# 在 3 宽 deck 铺满屏幕的缩放下,120 文档 px 的行距只合约 16 屏幕 px,第二
# 行的帧名会压到上一行的板上。
BOARD_STEP_X, BOARD_STEP_Y, PER_ROW = 2040, 1440, 3
def build():
boards = [cover(), background(), facts(), analysis(),
options(), prospect(), questions(), resolution()]
for index, board in enumerate(boards):
board["x"] = (index % PER_ROW) * BOARD_STEP_X
board["y"] = (index // PER_ROW) * BOARD_STEP_Y
return boards
# 对比度(WCAG 相对亮度比;投影场景正文自设门槛 4.5):
# c-ink on c-linen / c-band / c-deep 14.63 / 14.00 / 12.77
# c-ink-soft on c-linen / c-band / c-deep 6.70 / 6.41 / 5.85
# c-ink-faint on c-linen / c-band 4.29 / 4.11 ← 最低一对
# c-thread on c-linen / c-deep 6.11 / 5.34
# c-ink-faint 只承载页标、脚注与元信息标签(≥24px 的注释档),4.29 高于大
# 字 3.0 的地板;正文一律走 c-ink 或 c-ink-soft。
# c-rule 不参与:它是非文字装饰线(高 ≤2px),按 §4.4 的豁免规则排除。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "卷宗亚麻 · 文档备忘 deck")

View file

@ -0,0 +1,674 @@
#!/usr/bin/env python3
"""gridpaper-graphite-deck.op — 方格石墨 · 学术答辩档(8 页 1920×1080)
spec: openpencil-docs/openpencil/generation/deck-system-0809.md §2 · D10
落格:high formality × medium-high density × light
锚点是实验记录本,锚的是**「过程被记录下来因此可被复核」**这件事 ——
不锚烧杯、分子式、齿轮这类学科符号。三个母题:
方格纸 48px 见方的极淡格线,所有元素落在格上;对齐是**可被看见的**
标签舌 贴在内容块左上角、向左出血 24px 的一枚斜切标签,语义固定
蓝=定义/方法 · 绿=结果/示例 · 琥珀=局限/存疑
图注双联 展品与它的 so-what 强制成对:**没有 so-what 的展品不许上页**;
反过来,遮住展品后那句话仍然成立,说明展品冗余,删图
引证槽 页底一条 1px 细线之下的引证带。**任何出现数据、图、他人结论
的页面必须有引证槽**,空槽即缺陷
### 网格与版心的对账(这一档唯一需要算的地方)
spec 给的边距是「上 96 / 下 112 / 左右 112」,但 1920−112×2 = 1696 不是 48
的整数倍 —— 若照抄,「所有元素落在 48 的整数倍上」这条就在第一页破功。
本档的取法:**左 112 起排、内容宽取 35 格 = 1680,右边距因此是 128**。
上下同理:上 96(2 格)+ 内容 864(18 格)+ 下 120。左右不等本身还顺手
破掉了「万物居中且四边等距」这条 AI 指纹(spec §3.7 第 8 条)。
格线画成满幅而不是只画版心:记录本的格是纸的属性,不是版面的属性。
y=720 那一根格线同时充当 04 页柱图的基线 —— 图不另画轴,**基线就是纸上
本来就有的那条线**,这是「方格纸」这个母题少数几个能同时省掉装饰又增加
可信度的地方。
### 密度槽位的计数口径
spec §3.2 把「一个数值 / 一条要点」各计 1 槽。柱图的 5 个数值标 + 5 个轴标
若逐个计数,任何带图的页都会当场超载 —— 本档按**一个展品计 1 槽**、
**一个编号条目计 1 槽**来数(与「一条要点计 1」同构)。逐页槽数写在各页
函数的注释里,全部落在 spec 给的上限之内。
### 工程约束(spec §6)
- 6.3 线一律 `rectangle`,不用 `line`(layout:none 下 `line` 吃文档绝对坐标)。
- 6.4 layout:none 的 children[0] 在最上 —— 每页先内容、后页脚家具、
**最后才是格线**,否则格线会盖在正文上。
- 6.9 字体两层,产稿用保底层 `Noto Sans SC` + `Inter`。
- 6.10 渲染栈没有 tabular 数字:05 页表格的数值列宽全部写死。
- 6.13 rectangle 不递归渲染子节点 —— 标签舌、展品底一律 `frame`。
- 6.15 板位 x = (i%3)*2040、y = (i//3)*1440。
对比度实测见文件末尾,最低一对 3.83(`graphite.faint`,只承载 ≥24px 的
引证 / 页码,属 spec §4.4 的合法例外)。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import Ids, color_vars, frame, path, rect, solid, text, write_doc
ids = Ids()
# ---------------------------------------------------------------- 色板
#
# spec §2 D10 的 oklch 表。两处推导值得在代码里留一句:
# grid.line 只比底低 0.05 —— 格子必须「在那里但不参与竞争」,一旦能被
# 主动读到就从可信度变成了噪声;
# graphite 用带 chroma 的冷灰而不是纯黑 —— 模拟石墨反光,这是与「白底
# 黑字学术 PPT」唯一但足够的分野。
VARS = color_vars({
"c-paper": "#F4F8F8", # 页面底
"c-grid": "#E0E8E9", # 方格线(间距 48)
"c-panel": "#E8EEEF", # 展品底 / 方法块
"c-hair": "#CBD0D1", # 分隔线 / 表格线
"c-graphite": "#1F2329", # 主文字
"c-soft": "#575B60", # 次级文字
"c-faint": "#797E83", # 引证 / 页码(仅 ≥24px)
"c-blue": "#1D6294", # 索引:定义 / 方法(主 accent)
"c-green": "#33724C", # 索引:结果 / 示例
"c-amber": "#A17221", # 索引:局限 / 存疑
})
W, H = 1920, 1080
GRID = 48
# 版心:左 112 起、35 格宽、上 96 起、18 格高。右 128 / 下 120 是余数,
# 不是随手写的 —— 见模块 docstring 的对账。
LEFT, TOP = 112, 96
CONTENT_W, CONTENT_H = GRID * 35, GRID * 18 # 1680 × 864
RIGHT, BOTTOM = LEFT + CONTENT_W, TOP + CONTENT_H # 1792 / 960
CJK = "Noto Sans SC"
NUM = "Inter"
# ---------------------------------------------------------------- 字阶
COVER = (84, 1.10, -2) # round(84 × -0.02) = -2
TITLE = (60, 1.18, 0) # 论断句页标题,≤2 行
EXHIBIT = (36, 1.35, 0) # 展品标题 / 条目标题
BODY = (30, 1.70, 0) # 正文,**30 是地板不是目标**(spec §2 D10 第 6 条)
CITE = (24, 1.45, 0) # 引证 / 页码 / 轴标 / 引用列表
def th(step, lines=1):
size, line_height, _ = step
return round(size * line_height * lines)
# ---------------------------------------------------------------- 文本
def txt(name, content, step, weight, color, *, inner, family=CJK,
align=None, width="fill_container"):
"""文本节点,产稿时执行行长闸。
这里的断言比 `qa/cjkcheck.py` 更严:**不看汉字占比,每一条硬换行都要
落在容量内**。cjkcheck 会跳过拉丁占比高的串(按 1em 一个字算它们是假
阳性),但拉丁比汉字窄,用汉字容量去卡它只会更保守 —— 保守的断言不会
放过真问题,只会多改几句文案。
"""
size, line_height, spacing = step
per = int(inner // size)
# `fit_content` 的节点是收缩包裹的,永远不会折行 —— cjkcheck 也正是按
# `growth: auto` 跳过它们的。对它断言只会得到假阳性。
for line in ([] if width == "fit_content" else content.split("\n")):
assert len(line) <= per, (
f"{name}: 「{line}」{len(line)} 字 > 该块容量 {per} 字"
f"(inner={inner} fs={size})—— 改文案或换页型,不许缩字号"
)
return text(ids, name, content, size, weight, color, family=family,
line_height=line_height, spacing=spacing, width=width,
growth="auto" if width == "fit_content" else "fixed-width",
align=align)
def num(name, content, step, weight, color, *, width="fit_content",
align=None):
"""数字与拉丁走西文族。跨行跨列要对齐的数值列宽一律写死(spec §6.10)。"""
size, line_height, spacing = step
return text(ids, name, content, size, weight, color, family=NUM,
line_height=line_height, spacing=spacing, width=width,
growth="auto" if width == "fit_content" else "fixed-width",
align=align)
# ---------------------------------------------------------------- 结构
def block(name, x, y, width, children, *, gap=24, padding=None, fill=None,
align="start"):
node = frame(ids, name, width=width, height="fit_content",
layout="vertical", gap=gap, alignItems=align,
fill=fill or [])
if padding:
node["padding"] = padding
node["x"], node["y"] = x, y
node["children"] = children
return node
def row(name, x, y, width, children, *, gap=24, padding=None, fill=None,
align="start"):
node = frame(ids, name, width=width, height="fit_content",
layout="horizontal", gap=gap, alignItems=align,
fill=fill or [])
if padding:
node["padding"] = padding
if x is not None:
node["x"], node["y"] = x, y
node["children"] = children
return node
def hline(x, y, width, *, thickness=1, color="$c-hair"):
node = rect(ids, "细线", width=width, height=thickness,
fill=solid(color))
node["x"], node["y"] = x, y
return node
# ---------------------------------------------------------------- 方格纸
def grid_field():
"""满幅方格。**必须是每页 children 的最后一批** —— jian 的 index 0 在最上,
写在前面格线就会横穿正文,看起来像被划掉了。
格子锚在版心原点 (112, 96) 而不是画布原点:内容的左基线因此正好落在
一根格线上,「元素落在格上」这句话在第一眼就能被验证。
"""
lines = []
for k in range(-2, 38):
x = LEFT + k * GRID
if 0 <= x <= W:
node = rect(ids, "格线 · 竖", width=1, height=H,
fill=solid("$c-grid"))
node["x"], node["y"] = x, 0
lines.append(node)
for k in range(-2, 21):
y = TOP + k * GRID
if 0 <= y <= H:
node = rect(ids, "格线 · 横", width=W, height=1,
fill=solid("$c-grid"))
node["x"], node["y"] = 0, y
lines.append(node)
return lines
def page(name):
node = frame(ids, name, width=W, height=H, layout="none",
fill=solid("$c-paper"), clipContent=True)
node["children"] = []
return node
def finish(p, index, citation=None):
"""页脚家具 + 格线,每页最后一步。
引证槽的细线**每页都画**(含没有引证的封面与结论页):页码与页标位置
属于「不允许跨页变」的一栏(spec §3.3),线是那条基线的一部分;变的
只是线下写不写引证。
"""
p["children"].append(hline(LEFT, BOTTOM, CONTENT_W))
if citation:
p["children"].append(block("引证槽", LEFT, BOTTOM + 24, 1240, [
txt("引证", citation, CITE, 400, "$c-faint", inner=1240),
]))
p["children"].append(block("页码", RIGHT - 200, BOTTOM + 24, 200, [
num("页码", f"{index} / 8", CITE, 400, "$c-faint", width=200,
align="right"),
]))
p["children"] += grid_field()
return p
# ---------------------------------------------------------------- 标签舌
TAB_H = 40
TAB_BLEED = 24
def index_tab(x, y, label, color, *, width=208):
"""索引标签舌。左端 12px 斜切(非圆角),语义由颜色固定。
**向左出血 24px 是关键**:它让标签读作「贴上去的」而不是「排进去的」,
同时天然破坏了统一左边距 —— 每页的左基线因此不是一条死线。
高度取 40 而不是 spec 写的 32:标签内的文字要站在 24px 的字号地板之上
(spec §3.4 <20 直接 FAIL、20–28 是 WARN 带),32 的舌高塞不下 24 的字
还留呼吸。这是往上调,不是往下让。
"""
shape = path(ids, "标签舌 · 形", f"M12 0 L{width} 0 L{width} {TAB_H} L0 {TAB_H} Z",
width=width, height=TAB_H, fill=solid(color))
shape["x"], shape["y"] = 0, 0
caption = txt("标签舌 · 字", label, CITE, 600, "$c-paper", inner=width - 44,
width="fit_content")
caption["x"], caption["y"] = 30, 6
node = frame(ids, f"标签舌 · {label}", width=width, height=TAB_H,
layout="none", fill=[])
node["x"], node["y"] = x - TAB_BLEED, y
# children[0] 最上:字压在形上。
node["children"] = [caption, shape]
return node
# ================================================================ 01 封面
def cover():
"""槽位 5:短线不计(非文本)+ 主标 / 副标 / 作者 / 单位日期 / 页码。
重心压在版心下半 —— 整套八页里唯一一页把主体放在下三分之一。"""
p = page("01 封面")
mark = rect(ids, "起始短线", width=GRID, height=6, fill=solid("$c-blue"))
mark["x"], mark["y"] = LEFT, 384
p["children"].append(mark)
p["children"].append(block("主标", LEFT, 432, CONTENT_W, [
txt("主标题", "夜间照明强度与城市鸟类\n鸣唱起始时间的关系", COVER,
700, "$c-graphite", inner=CONTENT_W),
]))
p["children"].append(block("副标", LEFT, 672, CONTENT_W, [
txt("副标题", "三座城市 24 个样点、两个繁殖季的同步声学与照度监测",
BODY, 400, "$c-soft", inner=CONTENT_W),
]))
p["children"].append(row("落款", LEFT, 816, CONTENT_W, [
txt("作者", "陆停云 · 沈砚秋", CITE, 500, "$c-graphite",
inner=400, width="fit_content"),
txt("单位", "城市生态研究所 · 声景实验室", CITE, 400, "$c-faint",
inner=520, width="fit_content"),
num("日期", "2026.08", CITE, 400, "$c-faint"),
], gap=32, align="center"))
return finish(p, 1)
# =========================================================== 02 问题与缺口
GAP_COLS = [
("01", "夜间照明仍在扩张", [
"全球人工夜间照明面积每年",
"增长约百分之二。三座城市的",
"地面照度中位数十年间由",
"5 lux 升至 12 lux。",
]),
("02", "觅食有数据,鸣唱没有", [
"既有工作集中在觅食与迁徙",
"时序;鸣唱起始时间只有零散",
"记录,且多数未同步测量样",
"点的实际照度。",
]),
("03", "把两者同点同时量", [
"本研究在同一样点同步记录",
"地面照度与鸣唱起始时间,",
"给出两者的剂量—反应关系,",
"而不是相关系数。",
]),
]
def problem():
"""三段编号:现状 / 缺口 / 本文回答。槽位 6(标题 + 3 段 + 引证 + 页码),
上限 6 —— 这一页正好用满,再加一句就该拆页。"""
p = page("02 问题与缺口")
p["children"].append(block("页标题", LEFT, TOP, CONTENT_W, [
txt("标题", "已有研究说明照明推迟了觅食,\n却没有回答鸣唱是否同步位移",
TITLE, 700, "$c-graphite", inner=CONTENT_W),
]))
col_w = 528
for index, (no, head, lines) in enumerate(GAP_COLS):
x = LEFT + index * (col_w + GRID)
p["children"].append(block(f"缺口 {no}", x, 384, col_w, [
num("序号", no, CITE, 500, "$c-faint", width=col_w),
txt("小标题", head, EXHIBIT, 600, "$c-graphite", inner=col_w),
txt("说明", "\n".join(lines), BODY, 400, "$c-soft", inner=col_w),
], gap=20))
return finish(p, 2,
"[1] 陈黎 等,2024,城市生态学报,41(3): 217–229")
# ================================================================ 03 方法
# 每步的说明压成**一行**:四步的说明各占两行时,流程块会从 528 一路排到
# 1083,正好压进页底的引证槽(第一版实测被 audit 抓到四条 TEXT OVERLAP)。
# 按 spec §3.1 的顺序,第一动作是缩短文案 —— 不是缩字号、也不是删引证槽。
METHOD_STEPS = [
("01", "布点与配对", "八个样点覆盖 0.5 至 50 lux,亮暗成对"),
("02", "同步测量", "声学记录仪与照度计同点同夜同步记录"),
("03", "起始时间判读", "两名判读者独立标注,分歧交第三人裁定"),
("04", "统计模型", "混合线性模型,照度取对数,控温度与云量"),
]
def method():
"""方法块(蓝标签舌)+ 四步编号行。槽位 8(标题 + 方法块 + 4 步 + 引证 +
页码 = 8,上限 9)。编号行是横排三段式,与 02 的三栏不同型。"""
p = page("03 方法")
p["children"].append(block("页标题", LEFT, TOP, CONTENT_W, [
txt("标题", "在同一样点上把照度与鸣唱起始时间同步记录",
TITLE, 700, "$c-graphite", inner=CONTENT_W),
]))
p["children"].append(index_tab(LEFT, 200, "定义 · 方法", "$c-blue"))
p["children"].append(block("方法块", LEFT, 240, CONTENT_W, [
txt("方法陈述",
"本文把「鸣唱起始时间」定义为日落次日拂晓、样点上出现的第一段"
"持续两秒以上的种内鸣唱;\n「地面照度」取记录仪水平面上一米处的"
"照度中位数。两个量都在同一样点、同一夜内取得。",
BODY, 400, "$c-graphite", inner=CONTENT_W - 96),
], padding=[48, 48], fill=solid("$c-panel")))
steps = []
for index, (no, head, note) in enumerate(METHOD_STEPS):
if index:
steps.append(rect(ids, "步间细线", width="fill_container",
height=1, fill=solid("$c-hair")))
steps.append(row(f"步骤 {no}", None, None, "fill_container", [
num("序号", no, CITE, 500, "$c-faint", width=72),
txt("步骤名", head, EXHIBIT, 600, "$c-graphite", inner=336,
width=336),
txt("步骤说明", note, BODY, 400, "$c-soft", inner=1200,
width=1200),
], gap=24, padding=[18, 0], align="start"))
p["children"].append(block("流程", LEFT, 528, CONTENT_W, steps, gap=0))
return finish(p, 3,
"[2] 方沐 等,2023,动物行为学报,18(2): 96–108")
# =========================================================== 04 结果 · 图
# 五个照度档的鸣唱提前量(分钟)。12 px / 分钟,柱顶因此都落在整数像素上。
BARS = [("0.5 lux", 2.1), ("1 lux", 5.8), ("5 lux", 13.6),
("20 lux", 22.4), ("50 lux", 30.9)]
BAR_W, BAR_GAP, BASELINE, PX_PER_MIN = 144, GRID, 720, 12
def result_chart():
"""图左文右。展品占 20 格、解读占 14 格(58:42,不做 50/50)。
图上没有 y 轴、没有网格线、没有图例 —— 数值直接标在柱顶,而基线用的
就是纸上那根 y=720 的格线。只有末档走 accent 蓝,其余四档走中性:
结论在「最高一档」,着色即立场,立场只表达一次。
槽位 8(标题 + 展品 + 图注双联 + 右标题 + 右说明×2 + 引证 + 页码)。
展品按一个单位计 1 槽,口径见模块 docstring。
"""
p = page("04 结果 · 图")
p["children"].append(block("页标题", LEFT, TOP, CONTENT_W, [
txt("标题", "照度每上升十倍,鸣唱起始时间提前约九分钟",
TITLE, 700, "$c-graphite", inner=CONTENT_W),
]))
for index, (label, minutes) in enumerate(BARS):
x = LEFT + index * (BAR_W + BAR_GAP)
height = round(minutes * PX_PER_MIN)
top = BASELINE - height
is_key = index == len(BARS) - 1
bar = rect(ids, f"柱 {label}", width=BAR_W, height=height,
fill=solid("$c-blue" if is_key else "$c-hair"))
bar["x"], bar["y"] = x, top
p["children"].append(bar)
value = block(f"柱值 {label}", x, top - 56, BAR_W, [
num("数值", f"{minutes:.1f}", BODY, 600,
"$c-blue" if is_key else "$c-graphite", width=BAR_W,
align="center"),
])
p["children"].append(value)
p["children"].append(block(f"轴标 {label}", x, 736, BAR_W, [
num("轴标", label, CITE, 400, "$c-soft", width=BAR_W,
align="center"),
]))
# 图注双联:展品下一条 2px 蓝顶线,线下**一句结论**(不是图题)。
# 判据:遮住上面这张图,这句话就不成立 —— 成立的话说明图是冗余的。
p["children"].append(hline(LEFT, 816, 960, thickness=2, color="$c-blue"))
p["children"].append(block("图注双联", LEFT, 832, 960, [
txt("so-what", "提前量在 5 lux 之后加速,说明存在阈值而非线性响应;"
"\n照明规划的着力点因此在低照度段,不在总量。",
BODY, 400, "$c-graphite", inner=960),
]))
read_x, read_w = 1120, 672
p["children"].append(block("解读标题", read_x, 288, read_w, [
txt("解读标题", "阈值出现在\n城市常见照度区间内", EXHIBIT, 600,
"$c-graphite", inner=read_w),
]))
p["children"].append(block("解读一", read_x, 432, read_w, [
txt("解读一", "0.5 与 1 lux 之间差异不显著;\n"
"5 lux 起提前量增幅变陡,\n"
"而 5 至 20 lux 正是三座城市\n住宅区道路照明的常见区间。",
BODY, 400, "$c-soft", inner=read_w),
]))
p["children"].append(block("解读二", read_x, 684, read_w, [
txt("解读二", "把照度压到 5 lux 以下,提前量\n"
"的中位数回到十分钟以内 ——\n这是本文唯一可操作的阈值。",
BODY, 400, "$c-soft", inner=read_w),
]))
return finish(p, 4,
"[3] 照度与声学原始数据,城市生态研究所声景实验室,2026")
# =========================================================== 05 结果 · 表
TABLE_COLS = (384, 216, 372, 324, 312)
TABLE_HEAD = ("照度档(lux)", "样点数", "提前量中位数(分)", "四分位距", "p 值")
TABLE_ROWS = [
("0.5 – 1", "5", "2.1", "1.4", "0.31"),
("1 – 5", "5", "5.8", "2.0", "0.04"),
("5 – 15", "5", "13.6", "3.1", "< 0.01"),
("15 – 30", "5", "22.4", "3.8", "< 0.01"),
("30 – 50", "4", "30.9", "4.6", "< 0.01"),
]
def table_row(name, cells, *, weight, color, fill=None, head=False):
kids = []
for index, (width, content) in enumerate(zip(TABLE_COLS, cells)):
# 表头与数据用**同一条对齐规则**(首列左、数值列右)。第一版表头
# 一律左对齐,结果「样点数」贴在列首、「5」贴在列尾,读起来像两张
# 表叠在一起 —— 数值列的表头必须跟着数值走。
align = "left" if index == 0 else "right"
if head:
kids.append(txt(f"{name} · 格", content, BODY, weight, color,
inner=width, width=width, align=align))
else:
# 数值列全部走西文族 + 写死列宽:渲染栈没有 tabular 数字,
# 靠 fit_content 指望它们自己对齐,一行长一点整张表就抖。
kids.append(num(f"{name} · 格", content, BODY, weight, color,
width=width, align=align))
return row(name, None, None, "fill_container", kids, gap=24,
padding=[22, 16], fill=fill, align="start")
def result_table():
"""表格。表头 2px 蓝下线、行间 1px 细线、**末行无线**、斑马走展品底。
整框边线一根没有。槽位 10(标题 + 表头 + 5 行 + 双联 + 引证 + 页码)。
与 04 的分工是硬规则:趋势用图,**精确数值比较才用表**,两者不许同页。
"""
p = page("05 结果 · 表")
p["children"].append(block("页标题", LEFT, TOP, CONTENT_W, [
txt("标题", "分档统计给出的提前量与照度呈单调关系",
TITLE, 700, "$c-graphite", inner=CONTENT_W),
]))
rows = [table_row("表头", TABLE_HEAD, weight=600, color="$c-graphite",
head=True),
rect(ids, "表头线", width="fill_container", height=2,
fill=solid("$c-blue"))]
for index, cells in enumerate(TABLE_ROWS):
if index:
rows.append(rect(ids, "行间细线", width="fill_container",
height=1, fill=solid("$c-hair")))
rows.append(table_row(f"表行 {index + 1}", cells, weight=400,
color="$c-graphite",
fill=solid("$c-panel") if index % 2 else None))
p["children"].append(block("表格", LEFT, 240, CONTENT_W, rows, gap=0))
p["children"].append(hline(LEFT, 840, CONTENT_W, thickness=2,
color="$c-blue"))
p["children"].append(block("图注双联", LEFT, 856, CONTENT_W, [
txt("so-what", "1 至 5 lux 之间 p 值跨过 0.05,"
"这一档是统计上的分界,也是照明规范该写的那个数。",
BODY, 400, "$c-graphite", inner=CONTENT_W),
]))
return finish(p, 5,
"[3] 照度与声学原始数据,城市生态研究所声景实验室,2026")
# ================================================================ 06 局限
LIMITS = [
("样本偏在温带", [
"三座城市都位于北纬三十",
"到四十度之间。热带与高",
"纬度城市的鸣唱物候受日",
"长驱动更强,不宜外推。",
]),
("只覆盖六个物种", [
"六个物种约占样点内个体",
"的七成,剩下三成、尤其",
"是夜行性种类的响应仍是",
"空白。",
]),
("照度不等于光谱", [
"本文只记录了照度,未区",
"分光谱成分。蓝光占比可",
"能才是更直接的驱动量,",
"这需要另一组实验。",
]),
]
def limitation():
"""三条局限,各带一枚琥珀标签舌。槽位 7(标题 + 3 条 + 引证 + 页码)。
局限页写在结论页之前而不是被藏进附录 —— 答辩的可信度来自它。"""
p = page("06 局限")
p["children"].append(block("页标题", LEFT, TOP, CONTENT_W, [
txt("标题", "三个条件限制了这个结论能推广到多远",
TITLE, 700, "$c-graphite", inner=CONTENT_W),
]))
col_w = 528
for index, (head, lines) in enumerate(LIMITS):
x = LEFT + index * (col_w + GRID)
p["children"].append(index_tab(x, 288, f"局限 0{index + 1}",
"$c-amber", width=168))
p["children"].append(block(f"局限 {index + 1}", x, 360, col_w, [
txt("局限标题", head, EXHIBIT, 600, "$c-graphite", inner=col_w),
txt("局限说明", "\n".join(lines), BODY, 400, "$c-soft",
inner=col_w),
], gap=20))
p["children"].append(block("适用范围", LEFT, 744, CONTENT_W, [
txt("适用范围", "这三条都不改变结论的方向,只限定它的适用范围:"
"温带、六个物种、以照度为自变量。",
BODY, 400, "$c-graphite", inner=CONTENT_W),
]))
return finish(p, 6,
"[4] 韦知远,2025,光生物学与物候,7(1): 33–48")
# ================================================================ 07 结论
CONCLUSIONS = [
"照度与鸣唱提前量之间是单调的剂量—反应关系",
"把地面照度压到 5 lux 以下,提前量回到十分钟以内",
"调整灯具遮光角比压低总功率更省电,也更有效",
]
def conclusion():
"""收尾页是结论不是「谢谢」—— Q&A 期间它会一直挂在屏幕上,
那段时间应该被结论占据(spec §2 D10 Strictly avoid 1)。
槽位 6(标题 + 3 条 + 意义 + 页码)。"""
p = page("07 结论")
p["children"].append(index_tab(LEFT, 96, "结果 · 结论", "$c-green",
width=192))
p["children"].append(block("页标题", LEFT, 168, CONTENT_W, [
txt("标题", "把灯调暗两档,可以把鸣唱提前量压回十分钟以内",
TITLE, 700, "$c-graphite", inner=CONTENT_W),
]))
items = []
for index, line in enumerate(CONCLUSIONS):
if index:
items.append(rect(ids, "条间细线", width="fill_container",
height=1, fill=solid("$c-hair")))
items.append(row(f"结论 {index + 1}", None, None, "fill_container", [
num("序号", f"0{index + 1}", CITE, 500, "$c-faint", width=72),
txt("结论", line, EXHIBIT, 500, "$c-graphite", inner=1584,
width=1584),
], gap=24, padding=[26, 0], align="start"))
p["children"].append(block("结论列表", LEFT, 336, CONTENT_W, items, gap=0))
p["children"].append(block("意义", LEFT, 720, CONTENT_W, [
txt("意义", "这意味着城市照明规划可以不必在道路亮度与生态成本之间"
"二选一:\n把同样的流明压低照射角、留在路面上,两个目标能同时"
"达成。",
BODY, 400, "$c-soft", inner=CONTENT_W),
]))
return finish(p, 7)
# ================================================================ 08 引用
# 每条两行:**作者与年份一行、出处一行**。这是引用列表本来的排法,也顺手
# 把每一行都压进 34 字的容量内 —— 引证的可核对性来自结构,不来自挤在一行。
REFERENCES = [
("[1] 陈黎,周砚,2024", "城市生态学报,41(3): 217–229"),
("[2] 方沐,柯砚池,2023", "动物行为学报,18(2): 96–108"),
("[3] 声景实验室,2026", "内部数据集 SL-26-04"),
("[4] 韦知远,2025", "光生物学与物候,7(1): 33–48"),
("[5] 苏槿,2022", "声景与城市,3(4): 51–66"),
("[6] 何砚舟,罗青,2021", "鸟类学研究,12(2): 140–152"),
("[7] 池屿,2024", "照明工程学报,35(6): 88–97"),
("[8] 林停舟,2023", "生态学杂志,42(9): 2201–2213"),
("[9] 邬砚,2020", "城市环境与健康,9(3): 12–24"),
("[10] 谢岚溪,2025", "夜间生态学评论,2(1): 5–19"),
]
def references():
"""全页引证。两栏五行,24px —— 这一页是唯一允许整页只有一种字号的页,
因为它本来就不是幻灯片,是一份可被逐条核对的清单。
槽位 13(标题 + 10 条 + 联系 + 页码),上限 14。"""
p = page("08 引用")
p["children"].append(block("页标题", LEFT, TOP, CONTENT_W, [
txt("标题", "引用文献", EXHIBIT, 600, "$c-graphite", inner=CONTENT_W),
]))
col_w = 816
for index, (head, source) in enumerate(REFERENCES):
x = LEFT + (index // 5) * (col_w + GRID)
y = 240 + (index % 5) * 120
p["children"].append(block(f"引用 {index + 1}", x, y, col_w, [
txt("引用 · 作者", head, CITE, 500, "$c-graphite", inner=col_w),
txt("引用 · 出处", source, CITE, 400, "$c-soft", inner=col_w),
], gap=4))
p["children"].append(block("联系", LEFT, 864, CONTENT_W, [
txt("联系", "通信作者:陆停云(城市生态研究所 · 声景实验室)",
CITE, 400, "$c-faint", inner=CONTENT_W),
]))
return finish(p, 8)
# ---------------------------------------------------------------- 板位
BOARD_X, BOARD_Y, PER_ROW = 2040, 1440, 3
def build():
boards = [cover(), problem(), method(), result_chart(), result_table(),
limitation(), conclusion(), references()]
for index, board in enumerate(boards):
board["x"] = (index % PER_ROW) * BOARD_X
board["y"] = (index // PER_ROW) * BOARD_Y
return boards
# 对比度(WCAG 相对亮度比,spec §2 D10 色板表实算值):
# c-graphite on c-paper 14.75
# c-soft on c-paper 6.39
# c-faint on c-paper 3.83 ← 仅承载 ≥24px 的引证 / 页码 / 轴标
# c-graphite on c-panel 13.46(方法块、表格斑马行)
# c-blue on c-paper 6.08(标签舌反向同为 6.08)
# c-green on c-paper 5.37
# c-amber on c-paper 3.97 ← 标签舌上的 24px 纸色字,属大字档(≥3.0)
# c-grid / c-hair 只画线不承载文字,按 spec §4.4 第 2 条豁免。
#
# 字号地板(spec §3.4):最小 24,最大 84 ≥ 60 的层级线,84 / 30 = 2.8 ≥ 2.5。
# 单页字阶档数:01=3 / 02=4 / 03=4 / 04=4 / 05=3 / 06=4 / 07=4 / 08=2,均 ≤4。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "方格石墨 · 学术答辩档")

View file

@ -0,0 +1,773 @@
#!/usr/bin/env python3
"""ledger-tick-deck.op — 账簿勾格 · 竞品矩阵档(1920×1080 · 7 页)
deck 体系 D12。落格 medium-high formality × medium-high density × light。
定位:把「我们和别人各自能做什么」摊成一张能逐格核对的账 —— 三态记号、
自家列高亮、合计行给结论。
### 锚点与色彩推导(采样 → 收敛 → 论证)
- **采样**:手工账簿。淡黄账页、蓝红双色栏线(蓝画格、红画表头与合计)、
逐格勾画的记号、合计行的双线。锚的是**「逐格核对」这个动作本身**。
- **收敛**:底 = 账页 oklch(0.960 0.016 92);线色两支(蓝 H245 / 红
H25)**只作线不作文字**;记号三态用绿 + 中灰 + 浅灰。
- **论证一**:**「不满足」用留空的浅灰短横,不画叉**。红叉是对竞品的价
值判断,会让整份材料读作攻击性推销而不是评估;留空是事实陈述。这条同
时规避了「红绿对立评分」这个已知的可信度杀手。
- **论证二**:**只有自家列被着色,竞品列全部走中性**。着色即立场,立场
只表达一次 —— 整份 deck 里 `own.wash` 只出现在主矩阵的那一列。
- **论证三**:蓝红两支线色的对比只有 3.13 级,这是刻意的。它们是**线不
是字**,对比一旦拉高就会与记号争夺注意力;几何质检对非文字装饰线
(rectangle 且宽或高 ≤3px)按节点类型豁免,不按色值白名单豁免。
### 招牌母题
1. **三态记号 tri-tick** —— 满足 = 一个 26px 的绿勾(两段折线,2.5px
描边,非圆点);部分 = 一个 24×24 的中灰**半格**(正方形左半填充);
不满足 = 一条 20×2px 的浅灰短横。**三态在形状上就可分辨,不依赖颜
色** —— 这是无障碍地板,也是与「红绿打分表」的分野。
2. **自家列 own-column** —— 整列 `own.wash` 底 + 2px 绿色外框(框从表头
顶到合计线),列头一枚绿色实心 chip。
3. **合计双线与结论行 tally-rule** —— 表尾上方 1px + 2px 红色双线,其下
一行不是数字合计而是**一句结论**。账簿的合计惯例被借来托住「所以
呢」。
### 负约束(Strictly avoid,逐条对应 spec §2 D12)
- 不画红叉、不用红绿对立评分;不满足用留空短横。
- 不放竞品 logo、不放竞品截图、不放任何可被认作贬低的视觉。
- 只有自家列着色;竞品列一律中性,且列宽、行高、字号与自家列完全相同。
- 蓝线与红线永远不承载文字。
- 不用圆角、阴影、渐变。
- 三态记号形状可分辨,不做成「三个不同颜色的圆点」。
- 每一格只放一个记号字形,不放文字说明 —— 需要说明就进 05 / 06 页。
- 评估维度必须在 02 页给出可核实的定义;无定义的维度不进矩阵。
- 矩阵行数上限 8、列数上限 5;超了拆页,绝不缩行高。
- 结论行是句子不是分数。不做加权总分 —— 总分把所有取舍藏进一个数字里,
正是矩阵要避免的。
### 工程约束(spec §6,逐条踩过的坑)
- **字体走保底层**:只写 `Noto Sans SC` 与 `Inter`。首选字体(思源黑 /
霞鹜新晰黑 / IBM Plex Mono)不在渲染字体包内,写了会静默回退到 Roboto
并触发「量 A 字体画 B 字体」的裁切偏心。
- **数值与记号列的列宽写死**:渲染栈没有 tabular 数字,靠 fit_content 让
格子自己对齐必然抖。
- **不用 line 节点、不用 dashPattern**:格线、双线、刻度全部是
rectangle;勾是两段 rectangle + rotation(rotation 是**度**,绕节点中
心)。
- **格与格之间放 1px 真格线节点**,不靠相邻底色差分格:两个同色矩形贴在
一起会渲成一整块;插进去的 rule 同时让相邻单元格不再是「相邻」,
`SIBLING_JAM_GAP` 也就无从谈起。
- **叠放层 children[0] 在最上**:自家列外框写在数组第一个,否则会被表体
盖住。
- **rectangle 不递归渲染子节点**:图例里的「记号 + 说明」一律用 frame /
group,不用 rectangle 当容器。
- **顶层一次性给出整棵 7 帧树**,每帧显式写 x/y(板位 2040 × 1440)。
字号(§3.4 硬地板:任意文本 ≥20、注释 ≥22、全 deck 最大 ≥60、最大 ≥ 2.5 ×
正文):封面主标 88 / 1.10 / −2(= 3.14 × 正文)· 页标题 56 / 1.20 · 条目标
题 32 · 表头与行标签 28 · 正文 28 / 1.70 · 脚注与页码 24 / 1.5。
对比度实测见文件末尾;承载文字的最低一对是 4.14。
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import Ids, color_vars, frame, group, rect, solid, text, write_doc
ids = Ids()
VARS = color_vars({
"c-paper": "#F5F2E6", # 页面底 · 账页
"c-band": "#EBE6D8", # 斑马行
"c-ink": "#221F1B", # 主文字 14.63 : paper
"c-ink-soft": "#5A5652", # 次级文字 6.48
"c-ink-faint": "#78746F", # 脚注 / 页码 4.14
"c-rule-blue": "#88A2B9", # 账簿蓝格线(非文字)
"c-rule-red": "#BD7670", # 表头线 / 合计双线(非文字)
"c-tick": "#2F7442", # 记号:满足 / 自家框 5.06
"c-tick-half": "#888682", # 记号:部分满足(非文字)
"c-tick-pale": "#C6C4C1", # 记号:不满足(留空短横)
"c-own": "#DFF3E2", # 自家列整列底 14.11(c-ink)
})
CJK = "Noto Sans SC"
NUM = "Inter"
W, H = 1920, 1080
PAGES = 7
DECK_LABEL = "客服工单系统 · 能力核对表"
PAD_TOP, PAD_BOTTOM, PAD_X = 88, 104, 96
INNER = W - PAD_X * 2 # 1728
FS_COVER, FS_TITLE, FS_LEAD = 88, 56, 44
FS_ITEM, FS_BODY, FS_META = 32, 28, 24
LH_DISPLAY, LH_TITLE, LH_BODY, LH_META = 1.10, 1.20, 1.70, 1.50
# 矩阵。行标签列固定 360,其余四列等分;四条 1px 竖格线也要算进总宽,
# 否则表尾会溢出画幅(列宽没有自动分配,写错就是溢出)。
COL_LABEL, MATRIX_COLS = 360, 4
COL_DATA = (INNER - COL_LABEL - MATRIX_COLS) // MATRIX_COLS # 341
ROW_H = 72
assert COL_LABEL + MATRIX_COLS * COL_DATA + MATRIX_COLS == INNER
# ------------------------------------------------------------------ 骨架
def col(name, children, *, gap=0, width="fill_container", fill=None, **props):
node = frame(ids, name, width=width, height="fit_content",
layout="vertical", gap=gap, fill=fill or [], **props)
node["children"] = children
return node
def row(name, children, *, gap=0, align="start", width="fill_container",
fill=None, height="fit_content", **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(width, thickness=1, color="$c-rule-blue"):
"""一条横线。**只用 rectangle**:`line` 在 layout:none 下吃的是文档绝对
坐标,会跑到画布的另一个角落去。"""
return rect(ids, "格线", width=width, height=thickness, fill=solid(color))
def vrule(height=ROW_H, thickness=1, color="$c-rule-blue"):
return rect(ids, "竖格线", width=thickness, height=height,
fill=solid(color))
# 拉丁与数字在 CJK 字体里约占半个字身。取 0.6 而不是 0.5 是留一档余量:
# 这个系数只决定断言的松紧,宁可早报一行也不要漏一行。
LATIN_EM = 0.6
def line_em(line):
"""一行文字的宽度,单位是「字」。CJK 记 1,其余记 LATIN_EM。"""
return sum(1.0 if ch > "⿿" else LATIN_EM for ch in line)
def fits(content, size, width):
"""产稿期的折行断言:改文案当场报错,不必跑完闸再回来猜哪一行折了。
量的是**渲染真实宽度**(拉丁半宽),不是 cjkcheck 的 1em 保守模型 ——
后者会把数字混排句判成折行,而它实际排得下。两者是互补的:这里守「不
许发生自动折行」,闸守「万一折了会不会出孤字末行 / 行首标点」。
断点一律写死在文案里,所以每一硬行都要自己排得下。
"""
if not isinstance(width, (int, float)) or not isinstance(content, str):
return
if not content.strip():
return
per = width / size
for line in content.split("\n"):
if line_em(line) > per:
raise ValueError(
f"断行超容量:{line!r} 折合 {line_em(line):.1f} 字 > 每行 "
f"{per:.1f} 字(宽 {width} · 字号 {size})。按 §3.1 缩短文"
f"案、换页型或拆页,不缩字号。")
def body(content, *, size=FS_BODY, weight=400, color="$c-ink", width=None,
lh=LH_BODY, family=CJK, spacing=0, align=None, name="正文"):
"""一段文字。断点写死在文案里 —— 当前 wrap_text 对 CJK 是逐字符断行、
没有标点避头尾,交给自动折行会在「。」前面断开。"""
fits(content, size, width)
return text(ids, name, content, size, weight, color, family=family,
line_height=lh, width=width or "fill_container",
growth="fixed-width", align=align, spacing=spacing)
def mono(content, *, size=FS_META, weight=500, color="$c-ink-faint",
spacing=0, name="标注"):
"""数字与拉丁一律走西文族 —— 让中文字体去渲染数字会字宽不齐,列就抖
了。"""
return text(ids, name, content, size, weight, color, family=NUM,
line_height=LH_META, width="fit_content", growth="auto",
spacing=spacing)
def index_mark(number, *, color="$c-rule-red", width=46):
"""序号。**宽度写死** —— Inter 在当前栈里不保证等宽数字,`01` 比 `03`
窄,跟着它排的标题就会一行一个起点(实测可见)。"""
return text(ids, "序号", f"{number:02d}", FS_META, 500, color,
family=NUM, line_height=LH_META, width=width,
growth="fixed-width")
def footer(index):
"""每页同一位置的页脚。位置锁死(§3.3 的「页码与页标位置不许跨页
变」),内容左右分置。"""
return row("页脚", [
text(ids, "册名", DECK_LABEL, FS_META, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
mono(f"p. {index} / {PAGES}", name="页码"),
], gap=48, width="fill_container", justifyContent="space_between")
def page(name, index, children, *, gap=32, decor=(), anchor="start"):
"""一页。固定 1920×1080,绝不 fit_content:投影比例是硬约束。
页脚永远是正文层的最后一个孩子 + `space_between`,所以内容从上边距往下
排、页脚贴着下边距,不需要为每一页手算高度。
"""
content = frame(ids, f"{name} · 正文", width="fill_container",
height="fill_container", layout="vertical",
padding=[PAD_TOP, PAD_X, PAD_BOTTOM, PAD_X],
# 正文与页脚之间是固定的一档间距,不跟着页内节奏走:
# 页脚和脚注同为 24px 灰字,靠得近会读成同一段。
gap=56, justifyContent="space_between", fill=[])
stacked = [col("内容", list(children), gap=gap), footer(index)]
if anchor == "end":
# 构图重心下沉:一个撑满剩余高度的空撑子把内容顶到页脚上方。
# 不许页页都锚在同一个 y 上,收尾页尤其不该和内页一个重心。
spacer = frame(ids, "留白撑子", width="fill_container",
height="fill_container", layout="none", fill=[])
stacked.insert(0, spacer)
content["children"] = stacked
shell = frame(ids, name, width=W, height=H, layout="none",
fill=solid("$c-paper"), clipContent=True)
if decor:
ornament = frame(ids, f"{name} · 账页格线", width="fill_container",
height="fill_container", layout="none", fill=[])
ornament["children"] = list(decor)
# children[0] 在最上:正文层写在前面,格线永远在字的下面。
shell["children"] = [content, ornament]
else:
shell["children"] = [content]
return shell
def page_title(title):
return body(title, size=FS_TITLE, weight=700, lh=LH_TITLE, name="页标题")
# ------------------------------------------------------------------ 三态记号
#
# 三态在**形状**上就可分辨:勾(两段折线)/ 半格(正方形左半填充)/ 短横。
# 颜色只是第二重线索 —— 色觉障碍读者、黑白打印稿都要能读出这三态。
TICK_BOX = 26
TICK_STROKE = 2.5
def _segment(name, x1, y1, x2, y2, color):
"""一段带角度的笔画。rectangle + rotation(度,绕节点中心)——
`line` 节点在 layout:none 下会跑到文档绝对坐标去。"""
length = math.hypot(x2 - x1, y2 - y1)
angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
seg = rect(ids, name, width=round(length, 2), height=TICK_STROKE,
fill=solid(color))
seg["x"] = round((x1 + x2) / 2 - length / 2, 2)
seg["y"] = round((y1 + y2) / 2 - TICK_STROKE / 2, 2)
seg["rotation"] = round(angle, 2)
return seg
def tick_full():
"""满足:一个绿勾。两段折线,不是圆点、不是对号字符。"""
box = group(ids, "记号 · 满足", width=TICK_BOX, height=TICK_BOX,
layout="none", fill=[])
box["children"] = [
_segment("勾 · 长笔", 9.5, 20.5, 23.0, 5.0, "$c-tick"),
_segment("勾 · 短笔", 3.0, 14.0, 10.0, 21.0, "$c-tick"),
]
return box
def tick_half():
"""部分满足:一个正方形,左半填实。"""
outline = rect(ids, "半格 · 外框", width=24, height=24, fill=[],
stroke={"thickness": 1.5, "fill": solid("$c-tick-half")})
outline["x"], outline["y"] = 1, 1
filled = rect(ids, "半格 · 左半", width=12, height=24,
fill=solid("$c-tick-half"))
filled["x"], filled["y"] = 1, 1
box = group(ids, "记号 · 部分满足", width=TICK_BOX, height=TICK_BOX,
layout="none", fill=[])
box["children"] = [outline, filled]
return box
def tick_none():
"""不满足:一条留空的短横。**不画叉** —— 叉是价值判断,横是事实。"""
bar = rect(ids, "短横", width=20, height=2, fill=solid("$c-tick-pale"))
bar["x"], bar["y"] = 3, 12
box = group(ids, "记号 · 不满足", width=TICK_BOX, height=TICK_BOX,
layout="none", fill=[])
box["children"] = [bar]
return box
TICKS = {"full": tick_full, "half": tick_half, "none": tick_none}
# ------------------------------------------------------------------ 主矩阵
DIMENSIONS = [
("工单自动分派", "按技能组、负载与优先级自动指派,\n无需人工转派即计满足。"),
("多渠道接入", "邮件、网页表单、即时通讯、电话四类中,\n支持三类及以上即计满足。"),
("时效承诺", "可按客户分级设定响应与解决时限,\n并在超时前自动触发升级。"),
("知识库联动", "坐席能在工单内检索并插入知识条目,\n且能把新写的条目回写。"),
("数据可导出", "全量工单与会话可导出为结构化文件,\n导出不限频次、不限字段。"),
("私有化部署", "可部署在客户自有服务器,\n且功能与云端版本一致。"),
]
# (维度, 我方, 甲, 乙, 丙)。自家列一样只是三态之一 —— 六项里我们也有一项
# 只做到部分满足,写出来比写满六个勾可信。
MATRIX = [
("工单自动分派", "full", "full", "half", "none"),
("多渠道接入", "full", "full", "full", "half"),
("时效承诺", "full", "half", "none", "half"),
("知识库联动", "full", "half", "full", "none"),
("数据可导出", "full", "full", "half", "full"),
("私有化部署", "half", "none", "none", "full"),
]
COLUMN_HEADS = ["本产品", "竞品甲", "竞品乙", "竞品丙"]
def matrix_cell(state, *, own=False, zebra=False):
fill = solid("$c-own") if own else (solid("$c-band") if zebra else [])
cell = frame(ids, "记号格", width=COL_DATA, height=ROW_H,
layout="horizontal", justifyContent="center",
alignItems="center", gap=0, fill=fill)
cell["children"] = [TICKS[state]()]
return cell
def matrix_head():
"""表头行。自家列的列头是一枚绿色实心 chip,其余三列走中性文字 ——
着色即立场,立场只表达一次。"""
label = frame(ids, "表头 · 维度", width=COL_LABEL, height=ROW_H,
layout="horizontal", alignItems="center", padding=[0, 24],
gap=0, fill=[])
label["children"] = [
body("评估维度", size=FS_BODY, weight=600, color="$c-ink-soft",
lh=1.4, width=COL_LABEL - 48, name="表头文字"),
]
cells = [label]
for index, name in enumerate(COLUMN_HEADS):
own = index == 0
box = frame(ids, "表头格", width=COL_DATA, height=ROW_H,
layout="horizontal", justifyContent="center",
alignItems="center", gap=0,
fill=solid("$c-own") if own else [])
if own:
# 圆角字段整套一个都不写(默认就是直角)。写 `cornerRadius: 0`
# 语义上没错,但会让「grep 圆角零命中」这道机器核多一条噪声。
chip = frame(ids, "自家标记", width="fit_content",
height="fit_content", layout="horizontal",
padding=[8, 18], gap=0,
justifyContent="center", alignItems="center",
fill=solid("$c-tick"))
chip["children"] = [
text(ids, "自家标记文字", name, FS_BODY, 600, "$c-paper",
family=CJK, width="fit_content", growth="auto",
line_height=1.3),
]
box["children"] = [chip]
else:
box["children"] = [
text(ids, "表头格文字", name, FS_BODY, 500, "$c-ink-soft",
family=CJK, width="fit_content", growth="auto",
line_height=1.4),
]
cells.append(vrule())
cells.append(box)
return row("矩阵表头", cells, gap=0, height=ROW_H, align="stretch")
def matrix_row(label, states, zebra):
fill = solid("$c-band") if zebra else []
label_cell = frame(ids, "行标签", width=COL_LABEL, height=ROW_H,
layout="horizontal", alignItems="center",
padding=[0, 24], gap=0, fill=fill)
label_cell["children"] = [
# 定宽写死是为了让行标签折行在产稿期就报出来:行标签一折行,整行
# 72 的行高就被顶开,六行矩阵会跟着错位。
body(label, size=FS_BODY, weight=500, lh=1.4, width=COL_LABEL - 48,
name="行标签文字"),
]
cells = [label_cell]
for index, state in enumerate(states):
cells.append(vrule())
cells.append(matrix_cell(state, own=index == 0, zebra=zebra))
return row("矩阵行", cells, gap=0, height=ROW_H, align="stretch")
def matrix():
"""整张矩阵 + 自家列外框。
外框是一个叠在表体上的空描边 frame,**写在 children[0]**(layout:none
的 z-order 是第一个在最上);它没有填充,所以不会盖住格子里的记号。
"""
rows = [hrule(INNER), matrix_head(), hrule(INNER, 2, "$c-rule-red")]
height = 1 + ROW_H + 2
for index, (label, *states) in enumerate(MATRIX):
if index:
rows.append(hrule(INNER))
height += 1
rows.append(matrix_row(label, states, zebra=index % 2 == 1))
height += ROW_H
table = col("表体", rows, gap=0)
own_frame = frame(ids, "自家列外框", width=COL_DATA + 2, height=height,
layout="none", fill=[],
stroke={"thickness": 2, "fill": solid("$c-tick")})
own_frame["x"], own_frame["y"] = COL_LABEL, 0
stack = frame(ids, "主矩阵", width=INNER, height=height, layout="none",
fill=[])
stack["children"] = [own_frame, table]
return stack
def legend():
items = []
for state, label in (("full", "满足"), ("half", "部分满足"),
("none", "不满足")):
item = row("图例项", [
TICKS[state](),
text(ids, "图例文字", label, FS_META, 400, "$c-ink-soft",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
], gap=12, align="center", width="fit_content")
items.append(item)
items.append(text(ids, "图例说明",
"记号形状可分辨,不依赖颜色;「不满足」不画叉。",
FS_META, 400, "$c-ink-faint", family=CJK,
width="fit_content", growth="auto",
line_height=LH_META))
return row("三态图例", items, gap=44, align="center")
def tally(sentence):
"""合计双线 + 结论行。账簿在这里写合计,我们写一句话 —— 不做加权总
分,总分会把所有取舍藏进一个数字里。"""
return col("合计", [
hrule(INNER, 1, "$c-rule-red"),
frame(ids, "双线间隙", width=INNER, height=4, layout="none", fill=[]),
hrule(INNER, 2, "$c-rule-red"),
body(sentence, size=FS_ITEM, weight=500, lh=1.5, name="结论行"),
], gap=0)
# ------------------------------------------------------------------ 01 封面
def cover():
# 账页格线:右侧一片留空的账格。它是实线、满不透明、大到会被画幅裁切
# ——「4-6% 不透明度的装饰形状」那条指纹的反面。
ruling = []
for i in range(9):
line = hrule(760, 1)
line["x"], line["y"] = 1064, 268 + i * 72
ruling.append(line)
top = hrule(760, 2, "$c-rule-red")
top["x"], top["y"] = 1064, 196
ruling.insert(0, top)
for i in range(1, 4):
column = rect(ids, "账格竖线", width=1, height=9 * 72,
fill=solid("$c-rule-blue"))
column["x"], column["y"] = 1064 + i * 190, 196
ruling.append(column)
return page("01 封面", 1, [
col("封面文字", [
# 中文与拉丁各自一个节点:混在一个 Inter 节点里,中文会掉进回退
# 字体,同一行出现两种字面。
row("册头", [
text(ids, "册头中文", "竞品能力核对", FS_META, 500,
"$c-ink-faint", family=CJK, width="fit_content",
growth="auto", line_height=LH_META),
mono("· 2026 Q3", spacing=2, name="册头周期"),
], gap=10, align="center", width="fit_content"),
body("把能做与不能做\n摊在同一张账上", size=FS_COVER, weight=700,
lh=LH_DISPLAY, spacing=-2, width=1000, name="主标题"),
body("六项能力、四家产品,逐格核对。\n"
"记号只陈述事实,不打分、不排名。",
size=30, color="$c-ink-soft", width=1000, name="副标题"),
body("评估范围:面向 50–500 人团队的客服工单系统,\n"
"依据公开文档与试用账号实测,数据截止 2026-07-31。",
size=FS_BODY, color="$c-ink-soft", width=1000, lh=1.6,
name="评估范围"),
], gap=28),
], gap=32, decor=ruling)
# ------------------------------------------------------------------ 02 口径
def criteria():
cells = []
for index, (term, rule_text) in enumerate(DIMENSIONS):
item = col("维度定义", [
row("维度名", [
index_mark(index + 1),
text(ids, "维度术语", term, FS_ITEM, 600, "$c-ink",
family=CJK, width="fit_content", growth="auto",
line_height=1.35),
], gap=0, align="center"),
# 口径与术语对齐到同一条左线:序号列宽 46,正文让出同样多。
col("口径正文", [
body(rule_text, color="$c-ink-soft", width=734, lh=1.6,
name="判定口径"),
], padding=[0, 0, 0, 46]),
], gap=10, width=780)
cells.append(item)
left = col("口径左列", cells[0::2], gap=52, width=780)
right = col("口径右列", cells[1::2], gap=52, width=780)
return page("02 评估口径", 2, [
page_title("先说清楚每一格是怎么判的"),
body("六项维度、六条口径。判定只回答「做到没做到」,"
"不回答「做得好不好」——后者需要的证据这张表给不了。",
color="$c-ink-soft", lh=1.6, name="口径引导"),
row("口径两列", [left, right], gap=INNER - 780 * 2),
body("口径与记号规则在评估开始前确定,评估过程中未作调整;"
"无定义的维度不进矩阵。",
size=FS_META, color="$c-ink-faint", lh=LH_META, name="口径脚注"),
], gap=40)
# ------------------------------------------------------------------ 03 矩阵
def board():
return page("03 主矩阵", 3, [
page_title("六项能力,逐格核对"),
matrix(),
legend(),
tally("六项里我们满足五项,唯一的缺口是私有化部署;"
"今天必须私有部署的客户,应当选丙。"),
], gap=28)
# ------------------------------------------------------------------ 04 分位
SEG_W = 299
BAR_W = SEG_W * 4 + 3
QUARTILES = [
("工单首响时长", 0.62,
"落在第三四分位:比同类的中位数快,但没有到最快的那一档。"),
("单座席月成本", 0.28,
"落在第一四分位:自动分派省下的人力,直接反映在这一格上。"),
("私有化部署能力", 0.12,
"落在第一四分位的左端:这是我们承认的短板,排在明年一季度。"),
]
def quartile_bar(position):
"""四等分刻度条 + 一枚位置标。四段之间是 1px 真格线,不靠底色差分段
——两个同色矩形贴在一起会被渲成一整块。"""
segments = []
for index in range(4):
if index:
segments.append(vrule(40))
segments.append(rect(ids, "四分位段", width=SEG_W, height=40,
fill=solid("$c-band")))
track = row("刻度轨", segments, gap=0, width=BAR_W, height=40,
align="stretch")
track["x"], track["y"] = 0, 8
marker = rect(ids, "位置标", width=6, height=56, fill=solid("$c-tick"))
marker["x"], marker["y"] = round(position * BAR_W) - 3, 0
bar = frame(ids, "四分位条", width=BAR_W, height=56, layout="none",
fill=[])
bar["children"] = [marker, track]
return bar
def quartile_scale():
"""四分位刻度头。四段各自具名,位置标的含义写在同一行 —— 记号在这一档
从不靠图注之外的东西解释自己。"""
cells = []
for index in range(4):
if index:
cells.append(vrule(28, color="$c-paper"))
box = frame(ids, "刻度名", width=SEG_W, height=28,
layout="horizontal", justifyContent="center",
alignItems="center", gap=0, fill=[])
box["children"] = [mono(f"Q{index + 1}", name="刻度文字")]
cells.append(box)
scale = row("刻度头", cells, gap=0, width=BAR_W, height=28,
align="stretch")
note = row("位置标说明", [
rect(ids, "说明标", width=6, height=24, fill=solid("$c-tick")),
text(ids, "说明文字", "本产品所在位置", FS_META, 400, "$c-ink-soft",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
], gap=12, align="center", width="fit_content")
return row("刻度行", [scale, note], gap=48, align="center")
def quartiles():
groups = []
for name, position, reading in QUARTILES:
groups.append(col("分位组", [
row("分位名", [
text(ids, "分位标题", name, FS_ITEM, 600, "$c-ink",
family=CJK, width="fit_content", growth="auto",
line_height=1.35),
mono(f"{int(position * 100)}", color="$c-ink-faint",
name="分位读数"),
text(ids, "分位单位", "分位", FS_META, 400, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=LH_META),
], gap=10, align="center"),
quartile_bar(position),
body(reading, color="$c-ink-soft", width=1200, lh=1.6,
name="读法"),
], gap=16))
return page("04 分位刻度", 4, [
page_title("同类产品里,我们各自站在哪一段"),
quartile_scale(),
col("分位组列", groups, gap=52),
], gap=32)
# ------------------------------------------------------------------ 05 差距
GAPS = [
("私有化部署尚未开放",
"现状:仅提供专属云,客户不能把数据放在自己机房。",
"计划:2027 年一季度发布可交付的私有部署包,先对金融与医疗行业开放。"),
("电话渠道依赖第三方",
"现状:电话接入要另接一家服务商,工单里只能看到通话记录链接。",
"计划:四季度完成自建话务网关,通话录音与转写直接落在工单里。"),
("知识库回写要管理员确认",
"现状:坐席写的新条目需要管理员审核后才进知识库,平均滞后 2 天。",
"计划:三季度上线分级权限,高级坐席可直接发布,审核转为抽查。"),
]
def gaps():
items = []
for index, (title, now, plan) in enumerate(GAPS):
if index:
items.append(hrule(INNER))
items.append(row("差距条", [
tick_half(),
col("差距文字", [
text(ids, "差距标题", title, FS_ITEM, 600, "$c-ink",
family=CJK, width="fit_content", growth="auto",
line_height=1.35),
body(now, color="$c-ink-soft", width=1400, lh=1.55,
name="现状"),
body(plan, color="$c-ink", width=1400, lh=1.55, name="计划"),
], gap=8),
], gap=28, align="start"))
return page("05 差距", 5, [
page_title("三处我们还没做到的"),
body("差距按能被验证的方式写:现状是今天的事实,计划带月份。"
"没有计划的差距不写进这一页,写了也只是姿态。",
color="$c-ink-soft", lh=1.6, name="差距引导"),
col("差距列", items, gap=40),
], gap=40)
# ------------------------------------------------------------------ 06 优势
# 断点写死在文案里:列宽 532、字号 28 时一行 18 字,这里每行 ≤17 字。
STRENGTHS = [
("自动分派命中率",
"工单进队列后按技能组\n与实时负载自动指派,\n不需要人工转派。",
"试用期 3200 单,\n人工转派 47 单,占 1.5%。"),
("导出不设限",
"全量工单、会话与附件\n元数据可随时导出为\n结构化文件。",
"单次导出 12 万条耗时 96 秒,\n字段与后台完全一致。"),
("超时前先升级",
"按客户分级设定时限,\n剩余两成时长时自动\n升级到组长。",
"首响超时率从 8.4% 降到 2.1%,\n已连续两个季度。"),
]
def strengths():
columns = []
width = 532
for index, (title, what, proof) in enumerate(STRENGTHS):
if index:
# 列与列之间是一条真的账簿竖线,不是纯留白 —— 这一页因此仍读作
# 「账」,而不是三张并排的卡片。
columns.append(vrule(392))
fits(title, FS_ITEM, width)
columns.append(col("优势列", [
tick_full(),
text(ids, "优势标题", title, FS_ITEM, 600, "$c-ink", family=CJK,
width=width, growth="fixed-width", line_height=1.35),
body(what, color="$c-ink-soft", width=width, lh=1.7,
name="说明"),
hrule(72, 2, "$c-rule-red"),
row("证据行", [
text(ids, "证据标签", "证据", FS_META, 500, "$c-ink-faint",
family=CJK, width="fit_content", growth="auto",
line_height=1.6),
body(proof, color="$c-ink", width=width - 88, lh=1.6,
name="证据"),
], gap=16),
], gap=18, width=width))
return page("06 优势", 6, [
page_title("三处我们做到了,并且能被验证"),
body("每一条都配一个可复核的数字;没有数字的优势不写进这一页。",
color="$c-ink-soft", lh=1.6, name="优势引导"),
row("优势三列", columns, gap=32, align="start"),
], gap=40)
# ------------------------------------------------------------------ 07 结论
ACTIONS = [
"把私有化部署排进 2027 年一季度的交付计划,本月给出范围。",
"在报价单里写明电话渠道当前依赖第三方,不含糊带过。",
"把这张核对表随合同附送,三个月更新一次,口径不变。",
]
def closing():
actions = []
for index, line in enumerate(ACTIONS):
actions.append(row("动作条", [
index_mark(index + 1),
body(line, width=1400, lh=1.55, name="动作"),
], gap=0, align="start"))
return page("07 结论", 7, [
page_title("这张账读完之后"),
body("能力上的差距只有一处,\n而它恰好决定了一类客户能不能签。",
size=FS_LEAD, weight=600, lh=1.3, width=1400, name="总判断"),
# 合计双线,与主矩阵表尾同一支装置:它托住的是「所以呢」,不是给标
# 题加下划线。
col("结论合计线", [
hrule(INNER, 1, "$c-rule-red"),
frame(ids, "双线间隙", width=INNER, height=4, layout="none",
fill=[]),
hrule(INNER, 2, "$c-rule-red"),
], gap=0),
col("动作列", actions, gap=24),
body("来源:各产品公开文档与试用账号实测,数据截止 2026-07-31;"
"口径见第 2 页,逐格记号见第 3 页。",
size=FS_META, color="$c-ink-faint", lh=LH_META, name="来源脚注"),
], gap=32, anchor="end")
# ------------------------------------------------------------------ 板位
# 3 板一行。行距 1440(而不是 1200):画布在帧上方以固定屏幕偏移画帧名,
# 在 3 宽 deck 铺满屏幕的缩放下,120 文档 px 的行距只合约 16 屏幕 px,第二
# 行的帧名会压到上一行的板上。
BOARD_STEP_X, BOARD_STEP_Y, PER_ROW = 2040, 1440, 3
def build():
boards = [cover(), criteria(), board(), quartiles(), gaps(),
strengths(), closing()]
for index, node in enumerate(boards):
node["x"] = (index % PER_ROW) * BOARD_STEP_X
node["y"] = (index // PER_ROW) * BOARD_STEP_Y
return boards
# 对比度(WCAG 相对亮度比):
# c-ink on c-paper / c-band / c-own 14.63 / 13.16 / 14.11
# c-ink-soft on c-paper / c-band / c-own 6.48 / 5.83 / 6.25
# c-ink-faint on c-paper 4.14 ← 最低一对(≥24px 注释)
# c-tick on c-paper / c-own 5.06 / 4.90
# c-paper on c-tick(自家 chip 的反白字) 5.08
# c-rule-blue (3.13) / c-rule-red (3.13) / c-tick-half (3.24) 不参与:它们
# 全部是**非文字**的线与记号(rectangle 且宽或高 ≤3px,或纯几何记号),按
# §4.4 的豁免规则按节点类型排除 —— 对比一旦拉高,格线就会跟记号抢注意力。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "账簿勾格 · 竞品矩阵 deck")

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB