openpencil/templates/step0/_generators/oplib.py
Fini 98f70579bf fix(ai): compare the CJK tracking cap as a ratio, not a rounded number
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
2026-08-09 21:16:35 +08:00

262 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""Tiny helpers for authoring PenDocument (.op) JSON by hand.
Field names follow jian-ops-schema (camelCase, base+container flattened).
"""
import base64
import json
import os
class Ids:
def __init__(self):
self.n = 0
def __call__(self, prefix="n"):
self.n += 1
return f"{prefix}{self.n}"
def solid(color):
return [{"type": "solid", "color": color}]
def stroke(color, thickness=2):
return {"thickness": thickness, "fill": solid(color)}
def linear(angle, stops):
"""线性渐变填充。`stops` 是 (offset, color) 序列。
注意 op-design-lint 的对比度检测器只读 stops[0],所以「文字压在渐变
上」这件事必须自己按每个 stop 都量一遍(各 deck 生成器文件末尾的对比度
表就是这么来的),别指望 lint 兜住。
"""
return [{
"type": "linear_gradient",
"angle": angle,
"stops": [{"offset": offset, "color": color} for offset, color in stops],
}]
def radial(stops, *, cx=0.5, cy=0.5, radius=0.5):
"""径向渐变填充。cx/cy ∈ [0,1] 是节点框内的相对位置radius 相对 max(w,h)。
半径给到 0.5、最外一档 stop 取所在页的底色,光晕就正好在节点边界收干净,
不会留下一圈可见的方边TileMode::Clamp 会把边界外全刷成最后一个 stop
"""
return [{
"type": "radial_gradient",
"cx": cx, "cy": cy, "radius": radius,
"stops": [{"offset": offset, "color": color} for offset, color in stops],
}]
def mix(a, b, t):
"""把 a 按 t 混向 b返回不透明十六进制色。
装饰层可以直接用节点 opacity但**底色变量**不行lint 会把半透明当成
不透明来量对比度(`parse_hex_color` 丢 alpha所以凡是文字要压上去的
那一层,颜色一律在这里先算成实色再写进变量。
"""
ca = [int(a.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
cb = [int(b.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
return "#" + "".join(f"{round(x + (y - x) * t):02X}" for x, y in zip(ca, cb))
def frame(ids, name, **props):
node = {"type": "frame", "id": ids("f"), "name": name}
node.update(props)
return node
def rect(ids, name, **props):
node = {"type": "rectangle", "id": ids("r"), "name": name}
node.update(props)
return node
def path(ids, name, d, **props):
node = {"type": "path", "id": ids("p"), "name": name, "d": d}
node.update(props)
return node
def group(ids, name, **props):
"""A `group` lays out exactly like a frame but is NOT an image-drop target.
`op-editor-core/src/image_drop.rs::node_accepts_image_drop` matches only
Frame | Rectangle | Ellipse | Polygon | Path | Image — Group is excluded
("a structural wrapper with no painted body of its own"). Its `fill` is
also never painted, so use it purely as a transparent wrapper.
"""
node = {"type": "group", "id": ids("g"), "name": name}
node.update(props)
return node
def stack(ids, name, content, decor, *, width, height, fill, **props):
"""一帧的分层骨架:外壳 layout:none内容层在上、装饰层在下。
背景装饰大圆弧、光晕、ghost 数字、网格暗纹)不能直接挂在正文那个
flex 容器里 —— 它们会变成一个参与排版的兄弟,把内容挤走。所以外壳退成
layout:none只放两个 fill_container 的层:正文层照常做 flex装饰层做
绝对定位。
**装饰层必须是最后一个孩子**jian 的兄弟顺序是「index 0 最上」
`canvas_viewport_paint_mask.rs` 反着遍历 children写反了大圆弧就会
盖住正文。
对比度上这层结构是中性的lint 找底色只往上走祖先链,装饰兄弟不参与,
正文的底仍然是外壳这一层的 `fill`。所以装饰必须自己克制到不影响可读性
(本仓的做法:节点 opacity ≤0.5,且与底色同族)。
"""
shell = frame(ids, name, width=width, height=height, layout="none",
fill=fill, clipContent=True, **props)
if not decor:
shell["children"] = [content]
return shell
ornament = frame(ids, f"{name} · 装饰", width="fill_container",
height="fill_container", layout="none", fill=[])
ornament["children"] = decor
shell["children"] = [content, ornament]
return shell
def icon_font(ids, name, glyph, size, color, **props):
"""Lucide glyph as an `icon_font` node — non-fillable, so drops walk up."""
node = {
"type": "icon_font", "id": ids("i"), "name": name,
"iconFontName": glyph, "width": size, "height": size,
"fill": solid(color),
}
node.update(props)
return node
# Calibration, all measured off real renders (see report):
# "●" (U+25CF) at fontSize F paints a disc of 0.754*F px. Its line box is
# F*lineHeight tall, so lineHeight 1.0 wraps a 113px disc in a 149px box and
# injects ~17px of dead space below it. At lineHeight 0.78 the box is 1.0345*D
# and the ink starts at y~0, which is why that value is pinned here.
# Ink starts 0.0772*F in from the text node's left edge.
DOT_INK_RATIO = 0.754
DOT_LINE_HEIGHT = 0.78
DOT_INK_LEFT_RATIO = 0.0772
# `icon_font` scales the 24x24 lucide viewBox into the node box, so its ink is
# 0.844 of the declared size; a `path` node stretches the glyph to fill the box
# (ink 1.038 of size). This ratio keeps an icon_font swap ink-identical to the
# path it replaces.
ICONFONT_PER_PATH_PX = 1.038 / 0.844
def upload_disc(ids, name, diameter, disc_color, path_equiv_size, icon_color,
glyph="upload"):
"""Tinted disc + upload glyph, built ONLY from non-fillable node kinds.
A frame/ellipse disc or a `path` glyph is each a valid image-drop target
(`image_drop.rs::node_accepts_image_drop` matches Frame|Rectangle|Ellipse|
Polygon|Path|Image), so either would steal a drop aimed at the
placeholder's centre. group + text + icon_font are all excluded, so a drop
anywhere inside resolves outward to the placeholder box itself.
`path_equiv_size` is the size the old `path` icon used; it is converted so
the rendered glyph keeps the same ink footprint.
"""
fs = round(diameter / DOT_INK_RATIO)
disc = text(ids, f"{name} · 圆底", "", fs, 400, disc_color,
family="Inter", line_height=DOT_LINE_HEIGHT,
width="fit_content", growth="auto")
disc["x"] = -round(fs * DOT_INK_LEFT_RATIO)
disc["y"] = 0
gsize = round(path_equiv_size * ICONFONT_PER_PATH_PX)
glyph_node = icon_font(ids, f"{name} · 图标", glyph, gsize, icon_color)
glyph_node["x"] = round((diameter - gsize) / 2, 2)
glyph_node["y"] = round((diameter - gsize) / 2, 2)
# Box the group to the dot's LINE box so it can't grow past it.
box = group(ids, name, width=diameter, height=round(fs * DOT_LINE_HEIGHT),
layout="none", fill=[])
# children[0] paints last (topmost): glyph over disc.
box["children"] = [glyph_node, disc]
return box
def text(ids, name, content, size, weight, color, *, family=None,
line_height=None, width="fill_container", growth="fixed-width",
align=None, spacing=0):
"""Text node. NEVER emits height — sizing is content-driven."""
if family is None:
family = "Noto Sans SC" if weight >= 600 or size >= 34 else "Inter"
if line_height is None:
# CJK ladder: display/headings tighter, body loose (cjk-typography.md)
line_height = 1.25 if size >= 60 else 1.3 if size >= 34 else 1.6
# CJK 负字距上限,与 cjk-typography.md / deckkit.py / qa/trackcheck.py 同源:
# <48px 一律 0>=48px 不得比 -0.02em 更负。**判定用比值,不先 round 上限**
# ——round(76 × -0.02) = -2 会放行 -2而 76px 的真实上限是 1.52。
# 拉丁/数字节点豁免页码、序号、Stat Value 这类 display 用 -0.03~-0.05em
# 是正常排印,拿 CJK 的上限去卡它们是误伤。
# 夹在这个总入口而不是各生成器里:同一个字距字面量常被多个字号复用
# yearreview 一个 -2 同时喂 64px 与 48px逐处改必漏。
if spacing < 0 and any("" <= ch <= "鿿" for ch in str(content)):
cap = 0 if size < 48 else round(size * 0.02, 2)
if cap == int(cap):
cap = int(cap)
if abs(spacing) > cap:
spacing = -cap if cap else 0
node = {
"type": "text", "id": ids("t"), "name": name,
"content": content,
"fontFamily": family,
"fontSize": size,
"fontWeight": weight,
"fill": solid(color),
"lineHeight": line_height,
"letterSpacing": spacing,
"textGrowth": growth,
}
if width is not None:
node["width"] = width
if align:
node["textAlign"] = align
return node
# 空态占位提示的中性灰阶 —— 故意写成字面值、不走设计变量。
# 上传占位属于「产品控件」而非品牌表达,绑主色会让「换主色只改一处」出现
# 例外:用户改了 $c-accent其它元素全变、唯独占位圆还是旧色。中性灰对任何
# 主色都成立,所以这里烤死。
PLACEHOLDER_DISC = "#E5E7EB"
PLACEHOLDER_ICON = "#9CA3AF"
PLACEHOLDER_TITLE = "#4B5563"
PLACEHOLDER_SPEC = "#9CA3AF"
ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
def asset_fill(filename, mode="fit"):
"""Embed assets/<filename> as a data-URL image fill.
Baked hints live in fill[0]. A dropped image overwrites exactly that slot
(`image_fill_upload.rs`: `if fills.is_empty() { push } else { fills[0] = body }`),
so the hint is replaced rather than stacked under the user's screenshot.
"""
with open(os.path.join(ASSET_DIR, filename), "rb") as fh:
url = "data:image/png;base64," + base64.b64encode(fh.read()).decode()
return {"type": "image", "url": url, "mode": mode}
def write_doc(dst, variables, children, name):
doc = {
"version": "1.0.0",
"name": name,
"variables": variables,
"children": children,
}
with open(dst, "w", encoding="utf-8") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"wrote {dst}")
def color_vars(mapping):
return {k: {"type": "color", "value": v} for k, v in mapping.items()}