openpencil/templates/step0/_generators/tpl1.py
Fini c37a109e3c refactor(editor): wrap multi-board templates onto rows
Every multi-board template laid its boards out in one long horizontal
strip, so opening a seven-board carousel dropped the user onto a canvas
they had to pan sideways to survey. Three per row matches the deck kit,
and the extra row gap is deliberate: the canvas draws frame names at a
fixed screen-space offset, so at fit-to-screen zoom a plain column gap
lets the second row's labels sit on top of the boards above them.

knowledge-carousel additionally takes hard line breaks in two body
paragraphs. Greedy wrapping put a comma at the start of a line and left
a lone full stop on the last one, which are both CJK line-break faults;
authored breaks remove the engine's freedom to reintroduce them.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-09 19:55:00 +08:00

254 lines
11 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.

#!/usr/bin/env python3
"""screenshot-tutorial.op — 小红书 3:4 截图教程卡(封面 + 3 步骤 + CTA"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, frame, rect, text, solid, stroke, write_doc,
color_vars, group, upload_disc, asset_fill,
PLACEHOLDER_DISC, PLACEHOLDER_ICON,
PLACEHOLDER_TITLE, PLACEHOLDER_SPEC)
W, H, GAP = 1080, 1440, 120
# 3 板一行 —— 与 deck 体系deckkit.BOARDS_PER_ROW同一约定多板模板在画布上
# 分行铺开,而不是拖成一长排。行间距比列间距多 240 不是手滑:画布在帧上方以
# **屏幕空间**固定偏移画帧名,缩到能整屏看时 120 文档像素只剩十几个屏幕像素,
# 第二行的帧名会压到上一行的板上。
BOARDS_PER_ROW = 3
ROW_GAP = GAP + 240
PAD_X, PAD_Y = 72, 88
VARS = color_vars({
"c-bg": "#FDF8F3",
"c-surface": "#FFFFFF",
"c-ink": "#1A1512",
"c-muted": "#7D6F64",
"c-accent": "#FF5A2C",
"c-accent-soft": "#FFEDE5",
"c-border": "#EDE0D4",
})
ids = Ids()
def page(name, children, **extra):
# gap=48 是硬下限:步骤页的占位框用 fill_container 吃掉全部余量,
# 没有这个 gap说明文案会紧贴页脚taffy 会先扣 gap 再分配剩余空间)。
node = frame(
ids, name,
width=W, height=H, layout="vertical",
padding=[PAD_Y, PAD_X], gap=48,
justifyContent="space_between", alignItems="start",
fill=solid("$c-bg"), clipContent=True,
)
node["children"] = children
node.update(extra)
return node
def block(name, children, gap=32, **extra):
"""Transparent structural wrapper — no fill (design-principles.md)."""
node = frame(ids, name, width="fill_container", height="fit_content",
layout="vertical", gap=gap, fill=[], alignItems="start")
node["children"] = children
node.update(extra)
return node
def badge(label, *, fill_c, text_c, size=26):
node = frame(ids, f"徽章 · {label}", width="fit_content", height="fit_content",
layout="horizontal", padding=[12, 24], gap=0, cornerRadius=999,
alignItems="center", justifyContent="center", fill=solid(fill_c))
node["children"] = [
text(ids, "徽章文字", label, size, 600, text_c, width="fit_content",
growth="auto", line_height=1.4)
]
return node
def footer(page_no, total=5):
node = frame(ids, "页脚", width="fill_container", height="fit_content",
layout="horizontal", justifyContent="space_between",
alignItems="center", gap=16, fill=[])
node["children"] = [
text(ids, "页脚品牌", "@ 你的账号名", 26, 500, "$c-muted",
width="fit_content", growth="auto", line_height=1.4),
text(ids, "页码", f"{page_no:02d} / {total:02d}", 26, 500, "$c-muted",
width="fit_content", growth="auto", line_height=1.4),
]
return node
# 三个步骤页的空态提示文案 —— bake_hints.py 用它烘焙 assets/*.png
HINTS = [
("拖入你的截图", "支持 PNG / JPG建议宽度 ≥ 1080px"),
("拖入标注后的截图", "圈选/箭头建议用品牌色,粗细 4-6px"),
("拖入成品截图", "成品图建议留 5% 以上的四周留白"),
]
SLOT_W, SLOT_H = 936, 845 # 实测占位框布局尺寸,仅用于烘焙提示图
HINT_ASSET = "screenshot-tutorial-{i}.png"
def hint_children(hint, spec):
"""空态提示的矢量构造 —— 只被 bake_hints.py 用来烘焙成 PNG。
模板本身不再挂这些节点:它们被烤进占位框的 fill[0],这样拖图时
set_node_fill_image_url 直接覆写 fill[0]image_fill_upload.rs:88
`fills[0] = body`),提示随之消失,用户零手工。
"""
return [
upload_disc(ids, "上传图标", 112, PLACEHOLDER_DISC, 52,
PLACEHOLDER_ICON),
text(ids, "占位提示", hint, 32, 600, PLACEHOLDER_TITLE, align="center",
line_height=1.4),
text(ids, "占位规格", spec, 24, 400, PLACEHOLDER_SPEC, align="center"),
]
def shot_slot(idx):
"""截图占位框 —— 空态提示是 fill[0] 的内嵌 PNG没有子节点。
fill[1] 保留 $c-surface提示图透明底白卡仍由设计变量驱动拖图后
fill[0] 被换成用户截图coverfill[1] 依旧在底下兜白。
"""
node = frame(ids, "截图占位框", width="fill_container",
height="fill_container", cornerRadius=28,
fill=[asset_fill(HINT_ASSET.format(i=idx), "fit"),
solid("$c-surface")[0]],
stroke=stroke("$c-border", 3), clipContent=True)
return node
def deck_deco():
"""装饰用「一套图」示意 — 三张卡片叠放。
遵守 deck 三规则:后层是纯装饰矩形(无任何文字/图标),偏移只做 14/28px
的 peek不重排前层用不透明 surface 填充压住后层。
layout="none" 下 children[0] 最靠前。
"""
mini = frame(ids, "装饰卡 · 正面", x=0, y=0, width=360, height=240,
layout="vertical", padding=28, gap=16, cornerRadius=24,
alignItems="start", fill=solid("$c-surface"),
stroke=stroke("$c-border", 2))
mini["children"] = [
rect(ids, "示意 · 强调块", width=132, height=14, cornerRadius=7,
fill=solid("$c-accent")),
rect(ids, "示意 · 文本行 1", width="fill_container", height=12,
cornerRadius=6, fill=solid("$c-border")),
rect(ids, "示意 · 文本行 2", width=208, height=12, cornerRadius=6,
fill=solid("$c-border")),
]
deck = frame(ids, "封面装饰 · 一套图", width=388, height=268,
layout="none", fill=[])
deck["children"] = [
mini,
rect(ids, "装饰卡 · 后层 1", x=14, y=14, width=360, height=240,
cornerRadius=24, fill=solid("$c-accent-soft")),
rect(ids, "装饰卡 · 后层 2", x=28, y=28, width=360, height=240,
cornerRadius=24, fill=solid("$c-border")),
]
return deck
# ---------------------------------------------------------------- 01 封面
def cover():
head = block("封面头部", [
badge("新手教程", fill_c="$c-accent", text_c="#FFFFFF"),
deck_deco(),
], gap=64)
hero = block("封面主标题区", [
text(ids, "封面标题", "三步做出你的\n第一张教程图", 92, 700, "$c-ink"),
rect(ids, "标题高亮条", width=132, height=14, cornerRadius=7,
fill=solid("$c-accent")),
text(ids, "封面副标题",
"不用学软件,套模板换图换字,\n十分钟出一套能发的教程图。",
34, 400, "$c-muted"),
], gap=36)
return page("01 封面", [head, hero, footer(1)])
# ------------------------------------------------------------ 02-04 步骤页
def step(no, title, desc):
head = frame(ids, "步骤头部", width="fill_container", height="fit_content",
layout="horizontal", gap=20, alignItems="center", fill=[])
head["children"] = [
badge(f"STEP {no}", fill_c="$c-accent-soft", text_c="$c-accent"),
]
main = block(f"0{no+1} 内容", [
head,
text(ids, "步骤标题", title, 54, 700, "$c-ink"),
shot_slot(no - 1),
text(ids, "步骤说明", desc, 30, 400, "$c-muted"),
], gap=36, height="fill_container")
return page(f"0{no+1} 步骤 {no}", [main, footer(no + 1)])
# ---------------------------------------------------------------- 05 CTA
def recap_row(no, label):
dot = frame(ids, f"序号圆点 {no}", width=56, height=56, layout="horizontal",
alignItems="center", justifyContent="center", cornerRadius=28,
fill=solid("$c-accent-soft"))
dot["children"] = [
text(ids, "序号", str(no), 28, 700, "$c-accent", width="fit_content",
growth="auto", line_height=1.4)
]
row = frame(ids, f"回顾 {no}", width="fill_container", height="fit_content",
layout="horizontal", gap=24, alignItems="center", fill=[])
row["children"] = [dot, text(ids, "回顾文字", label, 32, 500, "$c-ink",
line_height=1.5)]
return row
def cta_page():
head = block("总结头部", [
badge("总结", fill_c="$c-accent", text_c="#FFFFFF"),
text(ids, "总结标题", "就这三步,\n你也能做出来。", 76, 700, "$c-ink"),
], gap=32)
recaps = block("回顾列表", [
recap_row(1, "选一套模板,直接打开"),
recap_row(2, "把截图拖进占位框"),
recap_row(3, "改文案,导出发布"),
], gap=24)
cta_inner = frame(ids, "关注卡内容", width="fill_container",
height="fit_content", layout="vertical", gap=16, fill=[],
alignItems="start")
cta_inner["children"] = [
text(ids, "关注标题", "关注我,持续更新模板", 40, 700, "#FFFFFF"),
text(ids, "关注副文案", "评论区回复「模板」,拿走这套源文件。",
28, 400, "#FFE7DE"),
]
cta = frame(ids, "关注引导卡", width="fill_container", height="fit_content",
layout="vertical", padding=[44, 44], gap=0, cornerRadius=28,
fill=solid("$c-accent"))
cta["children"] = [cta_inner]
# 四个块直接交给 page 的 space_between 分配;再包一层 fit_content 的
# body 会把内容全顶到上半页,底部留一大块空洞。
return page("05 结尾 CTA", [head, recaps, cta, footer(5)])
def build():
pages = [cover(), step(1, "截好你要讲的那一张图",
"截图只留关键区域,边缘留白裁掉。窗口截图记得关掉无关的标签页和通知,"
"画面越干净,读者越容易看懂你在讲哪一步。",),
step(2, "标出最该被看见的地方",
"在截图上加一个圈或箭头,只标一处。标注超过两个,读者就不知道该看哪里了,"
"重点越少,记住的越多。",),
step(3, "配一句人话说明",
"标题写「做什么」,正文写「怎么做」。一页只说一件事,说不完就拆成下一页,"
"不要把字塞满整张图。",),
cta_page()]
for i, p in enumerate(pages):
p["x"] = (i % BOARDS_PER_ROW) * (W + GAP)
p["y"] = (i // BOARDS_PER_ROW) * (H + ROW_GAP)
dst = sys.argv[1]
write_doc(dst, VARS, pages, "截图教程卡 · 小红书 3:4 模板")
if __name__ == "__main__":
build()