249 lines
9.4 KiB
Python
249 lines
9.4 KiB
Python
"""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
|
||
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()}
|