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
2702
crates/op-editor-core/assets/scene_templates/banxin-rule-deck.op
Normal file
7045
crates/op-editor-core/assets/scene_templates/dossier-linen-deck.op
Normal file
11996
crates/op-editor-core/assets/scene_templates/gridpaper-graphite-deck.op
Normal file
6756
crates/op-editor-core/assets/scene_templates/ledger-tick-deck.op
Normal file
4292
crates/op-editor-core/assets/scene_templates/sounding-navy-deck.op
Normal file
5716
crates/op-editor-core/assets/scene_templates/tidemark-slate-deck.op
Normal file
699
templates/step0/_generators/deck_sounding_navy.py
Normal 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")
|
||||
689
templates/step0/_generators/deck_tidemark_slate.py
Normal 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")
|
||||
327
templates/step0/_generators/deckkit.py
Normal 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
|
||||
573
templates/step0/_generators/tpl_deck_banxin.py
Normal 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(), "版心界行 · 中文原生排版档")
|
||||
640
templates/step0/_generators/tpl_deck_dossier.py
Normal 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")
|
||||
674
templates/step0/_generators/tpl_deck_gridpaper.py
Normal 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(), "方格石墨 · 学术答辩档")
|
||||
773
templates/step0/_generators/tpl_deck_ledger.py
Normal 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")
|
||||
BIN
templates/step0/previews/banxin-rule-deck-01.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
templates/step0/previews/banxin-rule-deck-02.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
templates/step0/previews/banxin-rule-deck-03.png
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
templates/step0/previews/banxin-rule-deck-04.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
templates/step0/previews/banxin-rule-deck-05.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
templates/step0/previews/banxin-rule-deck-06.png
Normal file
|
After Width: | Height: | Size: 347 KiB |
BIN
templates/step0/previews/banxin-rule-deck-07.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
templates/step0/previews/banxin-rule-deck-overview.png
Normal file
|
After Width: | Height: | Size: 909 KiB |
BIN
templates/step0/previews/dossier-linen-deck-01.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
templates/step0/previews/dossier-linen-deck-02.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
templates/step0/previews/dossier-linen-deck-03.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
templates/step0/previews/dossier-linen-deck-04.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
templates/step0/previews/dossier-linen-deck-05.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
BIN
templates/step0/previews/dossier-linen-deck-06.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
templates/step0/previews/dossier-linen-deck-07.png
Normal file
|
After Width: | Height: | Size: 409 KiB |
BIN
templates/step0/previews/dossier-linen-deck-08.png
Normal file
|
After Width: | Height: | Size: 311 KiB |
BIN
templates/step0/previews/dossier-linen-deck-overview.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-01.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-02.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-03.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-04.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-05.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-06.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-07.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-08.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
templates/step0/previews/gridpaper-graphite-deck-overview.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
templates/step0/previews/ledger-tick-deck-01.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
templates/step0/previews/ledger-tick-deck-02.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
templates/step0/previews/ledger-tick-deck-03.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
templates/step0/previews/ledger-tick-deck-04.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
templates/step0/previews/ledger-tick-deck-05.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
BIN
templates/step0/previews/ledger-tick-deck-06.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
templates/step0/previews/ledger-tick-deck-07.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
templates/step0/previews/ledger-tick-deck-overview.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
templates/step0/previews/sounding-navy-deck-01.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
templates/step0/previews/sounding-navy-deck-02.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
templates/step0/previews/sounding-navy-deck-03.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
templates/step0/previews/sounding-navy-deck-04.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
templates/step0/previews/sounding-navy-deck-05.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
templates/step0/previews/sounding-navy-deck-06.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
templates/step0/previews/sounding-navy-deck-07.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
templates/step0/previews/sounding-navy-deck-overview.png
Normal file
|
After Width: | Height: | Size: 862 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-01.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-02.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-03.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-04.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-05.png
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-06.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-07.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
templates/step0/previews/tidemark-slate-deck-overview.png
Normal file
|
After Width: | Height: | Size: 804 KiB |