openpencil/templates/step0/_generators/tpl_tutorial_diy.py
Fini 6fd0b343a1 fix(editor): tighten template copy that wrapped into orphan lines
A CJK line-break gate over all fifty-eight templates caught eleven
sets whose body copy wrapped into three-character orphan tails or
punctuation-led lines. Each line is shortened to fit its measured
column (paired sides stay symmetric where the layout demands it),
affected root heights re-measured, previews re-baked. The one
remaining gate report is a simulator false positive disproven by the
rendered frame.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-09 02:24:44 +08:00

345 lines
15 KiB
Python
Raw 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
"""diy-blueprint-guide.op — 手工 DIY 图解长图(1080×N 竖版)
教程档里唯一一张**带材料规格表**的模板。手工教程失败通常不在手上,在准备
上:木条买短了 5 厘米、螺丝买错了型号。所以这张图把「材料 + 规格 + 数量」
放在最前面,占的篇幅和步骤一样多。
风格取「蓝图 / 施工图」:牛皮纸底 + 墨蓝线稿 + 尺寸标注。这是九张教程里唯
一一张**只有两个色相**的(纸的暖褐 + 墨的蓝),因为蓝图本来就只有两色——
这个约束不是省事,是这套视觉语言的定义。
### 配色推导(采样 → 收敛 → 论证)
- **采样**:从实物采——牛皮纸包装的暖褐、施工图晒图的墨蓝、裁切标记的
白。三者都来自「动手做东西」这件事本身的物料。
- **收敛**:三档暖褐(纸 0.86 / 卡 0.93 / 线 0.75)+ 两档墨蓝(墨 0.19 /
次级 0.38)+ 一档白。**没有第三个色相,也没有「强调色」这个角色**。
- **论证**:层级不靠颜色,靠**线宽和实心块**:区块头是一条 8px 墨蓝短
线,重点是整块墨蓝反白,尺寸标注是 2px 细线。这正是施工图的做法——图
纸上从来只有一种墨,全靠线型区分含义。多给一个强调色,尺寸标注和步骤
序号就会开始抢同一个「重要」的位置。
### 负约束(本模板明令不做的事)
- **不引入第三个色相。** 破了这条,蓝图这套语言就散了(见配色论证)。
- 不用手账贴纸、胶带、图钉、木纹贴图这些「手作感」装饰——它们把牛皮纸底
变成廉价拼贴,而且全都要占位置。
- 不做圆角超过 8px 的卡片。施工图上的框是直角,8px 已经是妥协。
- 不用 emoji 工具图标;工具与材料一律走单色线性 lucide。
- 材料规格必须写单位和数量,写不出具体规格的材料就不该进清单。
- 步骤说明一到两行;需要三行说明的步骤要拆成两步。
- 不写「超简单 / 零基础 / 十分钟搞定」——手工教程的时间随熟练度差三倍,
给一个数就是骗人。用「难度」和「需要的工具」代替。
硬契约:
- 内容距边缘 ≥80px(这里 80)
- 配色全部走 color_vars;这张没有「主色」可换,要改只能整体换墨色
- 正文与背景对比度 ≥2.0(实测表见文件末尾)
- **CJK 行高比西文全线高 0.2**:display 1.2,标题 1.3,正文 1.7
- **CJK 负字距不超过 -0.02em**(74px 标题 → -1.4px = -0.019em)
- 汉字走 Noto Sans SC,数字与拉丁走 Inter(规格表全是数字,最吃这条)
- 顶层 frame 显式写 x/y
- 根高固定:ROOT_H 是量出来的(根设 fit_content 渲一次读高度)
- 六个步骤图位实测各 410×230(1.78:1);成品图位 920×420;根高 4016 由 fit_content
量出后回填
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, color_vars, frame, icon_font, rect, solid, stroke,
text, write_doc)
ids = Ids()
VARS = color_vars({
"c-bg": "#E8DCC6",
"c-card": "#F6EFE1",
"c-slot": "#DDCFB6",
"c-line": "#C4B294",
"c-ink": "#1B3350",
# 次级墨。第一版 #4E6076 压在 c-slot 上只有 4.20,差一点点到 AA 正文
# 门槛;成品图位那行 23px/400 的规格说明正好落在那上面,所以整体
# 压深一档到 4.82。
"c-muted": "#475769",
"c-inv": "#F6EFE1",
"c-inv-muted": "#A9BACC",
})
CJK = "Noto Sans SC"
NUM = "Inter"
W = 1080
EDGE = 80
INNER = W - EDGE * 2
LH_DISPLAY, LH_HEAD, LH_BODY = 1.2, 1.3, 1.7
# 量出来的根高(根设 fit_content 渲一次读高度回填)。
ROOT_H = 4016
STEP_SLOT_H = 230
HERO_SLOT_H = 420
# (材料, 规格, 数量)。写不出规格的不进清单——见负约束。
MATERIALS = [
("松木条", "2×4 cm 截面 · 长 60 cm", "4 根"),
("胶合板", "厚 12 mm · 40×30 cm", "1 块"),
("自攻螺丝", "3.5×35 mm", "16 颗"),
("砂纸", "180 目 + 320 目", "各 1 张"),
("木蜡油", "透明色 · 100 ml", "1 罐"),
]
TOOLS = [("手锯", "hammer"), ("电钻", "drill"), ("卷尺", "ruler"),
("角尺", "pencil-ruler")]
# (序号, 步骤名, 一到两行说明, 图位提示)
STEPS = [
("01", "量好再画线", "四根木条都量成 60 cm,用角尺画满一圈再锯。",
"画线特写"),
("02", "锯口留在线外", "锯片压着线的外侧走,锯完刚好到线。", "锯切中"),
("03", "先打引导孔", "先打 3 mm 孔,直接上螺丝会裂。", "钻孔位置"),
("04", "拧螺丝固定框", "对角线交替拧,一次只拧到八成紧。", "拧螺丝"),
("05", "上面板", "胶合板压在框上,四边各两颗螺丝。", "面板压合"),
("06", "打磨上油", "180 目磨平,320 目收光,再薄涂两遍油。", "打磨中"),
]
def col(name, children, *, gap=16, width="fill_container", align="start",
height="fit_content", **props):
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, alignItems=align, fill=[], **props)
node["children"] = children
return node
def row(name, children, *, gap=20, align="center", width="fill_container",
height="fit_content", **props):
node = frame(ids, name, width=width, height=height, layout="horizontal",
gap=gap, alignItems=align, fill=[], **props)
node["children"] = children
return node
def band(name, children, *, fill, pad, gap):
node = frame(ids, name, width="fill_container", height="fit_content",
layout="vertical", padding=pad, gap=gap, alignItems="start",
fill=fill)
node["children"] = children
return node
def section_head(title, note):
"""区块头。一条 8px 墨蓝短线 —— 全篇唯一的「强调」手段。"""
return col("区块头", [
rect(ids, "强调短线", width=72, height=8, fill=solid("$c-ink")),
text(ids, "区块标题", title, 44, 700, "$c-ink", family=CJK,
line_height=LH_HEAD),
text(ids, "区块说明", note, 26, 400, "$c-muted", family=CJK,
line_height=LH_BODY),
], gap=14)
# ------------------------------------------------------------------ 01 页头
def header():
tag = frame(ids, "标签", width="fit_content", height="fit_content",
layout="horizontal", padding=[10, 20], cornerRadius=4,
alignItems="center", justifyContent="center",
fill=solid("$c-inv"))
tag["children"] = [
text(ids, "标签文字", "木工 · 入门件", 24, 700, "$c-ink", family=CJK,
width="fit_content", growth="auto", line_height=1.4),
]
metas = []
for index, (label, value) in enumerate((("难度", "入门"),
("需要电动工具", "只要电钻"),
("成品尺寸", "60×40×35 cm"))):
if index:
metas.append(rect(ids, "竖分隔", width=2, height=46,
fill=solid("$c-muted")))
metas.append(col("总览项", [
text(ids, "总览名", label, 21, 400, "$c-inv-muted", family=CJK,
width="fit_content", growth="auto", line_height=1.4),
text(ids, "总览值", value, 27, 700, "$c-inv", family=CJK,
width="fit_content", growth="auto", line_height=1.3),
], gap=4, width="fit_content"))
return band("01 页头", fill=solid("$c-ink"), pad=[76, EDGE, 68, EDGE],
gap=26, children=[
tag,
text(ids, "主标题", "一张边几\n六步做完", 74, 700, "$c-inv",
family=CJK, line_height=LH_DISPLAY, spacing=-1.4),
text(ids, "副标题",
"先把材料按规格备齐,剩下六步就只是重复:量、锯、钻、拧。",
27, 400, "$c-inv-muted", family=CJK, line_height=LH_BODY),
row("总览条", metas, gap=26, width="fit_content"),
])
# ------------------------------------------------------------------ 02 材料
def materials():
rows = []
for index, (name, spec, count) in enumerate(MATERIALS):
if index:
rows.append(rect(ids, "横分隔", width="fill_container", height=2,
fill=solid("$c-line")))
rows.append(row("材料行", [
col("材料名区", [
text(ids, "材料名", name, 29, 600, "$c-ink", family=CJK,
line_height=LH_HEAD),
text(ids, "材料规格", spec, 23, 400, "$c-muted", family=CJK,
line_height=1.5),
], gap=4),
text(ids, "材料数量", count, 27, 700, "$c-ink", family=CJK,
width=120, align="right", line_height=1.3),
], gap=20, align="center", padding=[18, 0]))
sheet = col("材料表", rows, gap=0, padding=[26, 28])
sheet["fill"] = solid("$c-card")
sheet["stroke"] = stroke("$c-line", 2)
sheet["cornerRadius"] = 8
tool_chips = []
for label, glyph in TOOLS:
chip = row("工具", [
icon_font(ids, "工具图标", glyph, 24, "$c-ink"),
text(ids, "工具名", label, 24, 600, "$c-ink", family=CJK,
width="fit_content", growth="auto", line_height=1.4),
], gap=10, width="fit_content", padding=[12, 18])
chip["stroke"] = stroke("$c-ink", 2)
chip["cornerRadius"] = 6
tool_chips.append(chip)
return band("02 材料", fill=[], pad=[64, EDGE, 0, EDGE], gap=26,
children=[
section_head("先备料,别边做边买",
"规格照抄就行;差 5 mm 也能装,差 5 cm 装不上。"),
sheet,
row("工具条", tool_chips, gap=14, width="fill_container"),
])
# ------------------------------------------------------------------ 03 步骤
def step_card(no, name, desc, hint):
slot = frame(ids, "步骤图位", width="fill_container", height=STEP_SLOT_H,
layout="vertical", gap=8, alignItems="center",
justifyContent="center", cornerRadius=6,
fill=solid("$c-slot"))
slot["children"] = [
icon_font(ids, "图位图标", "camera", 32, "$c-muted"),
text(ids, "图位提示", hint, 23, 600, "$c-ink", family=CJK,
align="center", line_height=1.4),
]
card = col("步骤卡", [
slot,
row("步骤头", [
text(ids, "步骤序号", no, 30, 700, "$c-ink", family=NUM,
width=54, line_height=1.2),
text(ids, "步骤名", name, 28, 600, "$c-ink", family=CJK,
line_height=LH_HEAD),
], gap=8, align="center"),
text(ids, "步骤说明", desc, 23, 400, "$c-muted", family=CJK,
line_height=LH_BODY),
], gap=14, padding=[20, 20], height="fill_container")
card["fill"] = solid("$c-card")
card["cornerRadius"] = 8
card["stroke"] = stroke("$c-line", 2)
return card
def steps():
cards = [step_card(*s) for s in STEPS]
grid_rows = []
for start in (0, 2, 4):
grid_rows.append(row("步骤排", cards[start:start + 2], gap=20,
align="stretch"))
return band("03 步骤", fill=[], pad=[64, EDGE, 0, EDGE], gap=26,
children=[
section_head("六步,按顺序来",
"每一步做完都停下来量一次,错了这时候还能救。"),
col("步骤网格", grid_rows, gap=20),
])
# ------------------------------------------------------------------ 04 成品
def result():
slot = frame(ids, "成品图位", width="fill_container", height=HERO_SLOT_H,
layout="vertical", gap=12, alignItems="center",
justifyContent="center", cornerRadius=8,
fill=solid("$c-slot"))
slot["children"] = [
icon_font(ids, "成品图标", "image", 46, "$c-muted"),
text(ids, "成品提示", "把你做完的成品拍进来", 28, 600, "$c-ink",
family=CJK, align="center", line_height=LH_HEAD),
text(ids, "成品规格", "建议 16:9,光从侧面打,木纹才看得出",
23, 400, "$c-muted", family=CJK, align="center",
line_height=LH_BODY),
]
return band("04 成品", fill=[], pad=[64, EDGE, 0, EDGE], gap=26,
children=[
section_head("做完长这样", "尺寸可以改,结构不要改——四条腿的受力靠框。"),
slot,
])
# ------------------------------------------------------------------ 05 页脚
CHECKS = [
"锯之前把线画满一圈,只画一面必歪。",
"螺丝拧不进去先加深引导孔,别硬拧。",
"上油前把粉尘擦干净,擦不净会留白点。",
]
def footer():
items = []
for line in CHECKS:
box = frame(ids, "方框", width=36, height=36, layout="horizontal",
alignItems="center", justifyContent="center",
fill=solid("$c-inv"))
box["children"] = [icon_font(ids, "勾", "check", 22, "$c-ink")]
items.append(row("自检项", [
box,
text(ids, "自检文字", line, 25, 400, "$c-inv-muted", family=CJK,
line_height=LH_BODY),
], gap=16, align="start"))
return band("05 页脚", fill=solid("$c-ink"), pad=[60, EDGE, 52, EDGE],
gap=24, children=[
text(ids, "页脚标题", "开工前再看一眼", 40, 700, "$c-inv", family=CJK,
line_height=LH_HEAD),
col("自检列表", items, gap=14),
row("署名行", [
text(ids, "账号名", "@ 你的账号名", 25, 600, "$c-inv", family=CJK,
width="fit_content", growth="auto", line_height=1.4),
text(ids, "更新说明", "每月一件能自己做的家具", 23, 400,
"$c-inv-muted", family=CJK, width="fit_content",
growth="auto", line_height=1.4),
], gap=16, justifyContent="space_between"),
])
def build():
page = frame(ids, "手工图解长图", width=W, height=ROOT_H, layout="vertical",
gap=0, fill=solid("$c-bg"), clipContent=True)
page["children"] = [header(), materials(), steps(), result(), footer()]
page["x"], page["y"] = 0, 0
return [page]
# 对比度(WCAG 相对亮度比,op-design-lint 门槛 2.0;数值由脚本实测):
# c-ink on c-bg 9.46 c-muted on c-bg 5.46
# c-ink on c-card 11.22 c-muted on c-card 6.47
# c-ink on c-slot 8.36 c-muted on c-slot 4.82
# c-inv on c-ink 11.22 c-inv-muted on c-ink 6.47
# 承载正文的最低一对是 4.82(成品图位的规格说明压在 c-slot 上),高于 AA
# 正文门槛 4.5。整张图只有两个色相,所以这张表也只有四行——没有「强调色
# 压在什么底上」这一类需要单独交代的组合。c-line 只用于 hairline 与分隔,
# 是非文字图形。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "手工 DIY 图解长图")