The negative-tracking cap was stated as `round(fontSize * -0.02)`, and rounding it was wrong in both directions: at 76px it let -2 through when the real cap is 1.52, and at 72px it failed a legitimate -1.4 because the cap rounded down to 1. A rule that both over- and under-fires is not a threshold worth tuning, so the comparison is now against the ratio itself with fractional values allowed. Two narrowing decisions come with it. The cap applies only to runs that actually contain Han characters — Latin and numeric display (page numbers, stat values) legitimately sets -0.03 to -0.05em, and 160 nodes across the library were being flagged for it. And the 64px boundary is gone, because below 48px the ratio is already self-limiting. The clamp sits in `text()`, the one entry every generator goes through: a single tracking literal is routinely shared by several sizes, so fixing call sites one at a time is guaranteed to miss some. `trackcheck` makes the rule enforceable outside the two generators that imported the kit, which is how the drift got in unnoticed. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
344 lines
15 KiB
Python
344 lines
15 KiB
Python
"""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 正文上直接让字面相撞。
|
||
# ≥48px 允许到 |letterSpacing| <= fontSize × 0.02(即 -0.02em),可带小数。
|
||
# 大写拉丁小标签(不含汉字)允许 +1~+2 的正字距;含汉字的一律 0。
|
||
#
|
||
# 判定用**比值**,不要先把上限 round 掉:72px 的上限是 1.44,-1.4 合法、-2 不合法。
|
||
# 先 round 会把 -1.4 误判成越界(round(-1.44) = -1),那是规则的 bug 不是稿子的。
|
||
# 分界只留 48 一道:比值本身在小字号上就是自限的(48px 只允许 0.96px,不到一个
|
||
# 像素),再加一道 64 的门槛只是多一个阈值,并不多挡住什么。
|
||
DISPLAY_TRACK = -0.02
|
||
TRACK_MIN_SIZE = 48
|
||
TRACK_EPS = 1e-6
|
||
|
||
|
||
def track(size):
|
||
"""display 档的负字距上限(含小数)。小于 48px 一律 0。"""
|
||
return round(size * DISPLAY_TRACK, 2) if size >= TRACK_MIN_SIZE else 0
|
||
|
||
|
||
def latin_track(content, value=1.0):
|
||
"""纯拉丁/数字小标签才给正字距;混了汉字就归零。"""
|
||
return value if all(ch < "⺀" for ch in content) else 0
|
||
|
||
|
||
def has_han(content):
|
||
"""文案是否含汉字。负字距上限只约束 CJK,拉丁/数字节点豁免。"""
|
||
return isinstance(content, str) and any("一" <= ch <= "鿿" for ch in content)
|
||
|
||
|
||
# ---------------------------------------------------------------- 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} 不成对")
|
||
|
||
# —— 字距:<48px 一律 0 且绝不为负;≥48px 不得比 -0.02em 更负。
|
||
# 只管含汉字的节点:拉丁/数字的 display 用 -0.03~-0.05em 是正常排印,
|
||
# 拿 CJK 的上限去卡页码和 Stat Value 是误伤。
|
||
spacing = node.get("letterSpacing", 0)
|
||
if spacing < 0 and has_han(content) and (
|
||
size < TRACK_MIN_SIZE or abs(spacing) > size * -DISPLAY_TRACK + TRACK_EPS
|
||
):
|
||
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
|