openpencil/templates/step0/_generators/tpl_lecture_light.py

520 lines
22 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
"""lecture-deck-light.op — 浅色课件 deck封面 / 目标 / 讲解 / 例题 / 对比 / 小结)
课件跟汇报 deck 不是同一件事:听众要跟着做笔记,所以正文比路演更密、更
完整,序号和步骤必须一眼数得清;同时它会被投在教室最后一排也看得见的地
方,所以字号一点不能省。米白纸底 + 墨绿,是为了长时间盯着看不刺眼。
这一版加了三样东西,都是为了把「白底加白卡」做出纸的层次:
1. **整套贯穿一张极淡的坐标网格。** 装饰层里 120px 一格的墨绿细线,压在
内容之下(`oplib.stack`),所以只在留白处看得见 —— 卡片一压上去就自然
盖住。这套讲的是函数与导数,坐标纸本身就是内容的一部分,不是花纹。
2. **知识点卡片有左侧色条。** 每张卡最左边一道 10px 的墨绿,是这套课件的
「知识点」标记;对比表、例题卡这类「不是知识点」的块一律不带,色条因此
有语义而不只是装饰。
3. **例题页有 ghost 大序号。** 满色 + 6% 图层透明度,压在内容之下。
字阶也拉开了:封面 116 / 正文 30近 4 倍),页标题 76 / 正文 28展示字
统一收紧字距。
排版遵循 skills/domains/slides.md 的硬契约:
- 每帧固定 1920×1080绝不 fit_content内容距边缘 ≥100这里 120
- 正文 ≥24取 26-30标题 ≥40行高标题 1.1-1.2、正文 1.4+
- 最多 2 个字体族Noto Sans SC 排中文Inter 排数字与公式)
- 2-3 个主色 + 中性色,强调色只用于强调
纸白底上的对比度WCAG实测见文件末尾最低一对是 5.01:1全部高于 4.5。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, color_vars, frame, rect, solid, stack, stroke, text,
write_doc)
ids = Ids()
# 米白纸 + 墨绿。底色刻意不是纯白:投影时纯白底会把整间教室照亮,米白既
# 压得住反光,也让白色卡面浮得起来(白卡 / 纸底亮度比 1.16,看得出层次)。
VARS = color_vars({
"c-bg": "#F2EEE2",
"c-surface": "#FFFFFF",
"c-ink": "#17211C",
"c-muted": "#55635A",
"c-accent": "#1B6B4C",
"c-accent-soft": "#D8E9DE",
"c-border": "#D9D2C2",
# 坐标网格线。**唯一带 alpha 的变量**网格是文字的兄弟不是祖先lint
# 找底色只走祖先链碰不到它;而同一根线要同时压在米白纸底和白卡边缘上,
# 只有半透明才两边都成立(实色必然在其中一边露馅)。
"c-grid": "#1B6B4C14",
})
W, H = 1920, 1080
EDGE = 120
CJK = "Noto Sans SC"
NUM = "Inter"
# 网格间距。120 = EDGE所以第一条线正好落在内容的左基线上网格与版心是
# 同一套尺度而不是两套。
GRID_STEP = 120
def paper_grid():
"""一张极淡的坐标纸。整套课件唯一的装饰语言。
只画线不画点:点阵在投影仪上会摩尔纹,细线不会。线压在内容之下,所以
卡片一盖就消失,看得见的永远只是留白里的那几格。
"""
lines = []
for index in range(1, (H + GRID_STEP - 1) // GRID_STEP):
line = rect(ids, "网格横线", width=W, height=2, fill=solid("$c-grid"))
line["x"], line["y"] = 0, index * GRID_STEP
lines.append(line)
for index in range(1, (W + GRID_STEP - 1) // GRID_STEP):
line = rect(ids, "网格竖线", width=2, height=H, fill=solid("$c-grid"))
line["x"], line["y"] = index * GRID_STEP, 0
lines.append(line)
return lines
def ghost_number(label, x, y, *, size=460):
"""页码做成背景上的巨大数字。
「满色 + 图层透明度」而不是「把颜色调到接近底色」:换主色时它自己跟着
走;而且 lint 量的是文字原色(它不看节点 opacity不会留下一条假的
低对比记录。
"""
node = text(ids, "ghost 页码", label, size, 700, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0,
spacing=-round(size * 0.05))
node["x"], node["y"] = x, y
node["opacity"] = 0.06
return node
def slide(name, *, gap=56, justify="start", fill="$c-bg", extra_decor=()):
"""一帧课件。固定 1920×1080 —— 投影比例是硬约束,不是建议。"""
body = frame(ids, f"{name} · 正文", width="fill_container",
height="fill_container", layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=[])
body["children"] = []
# ghost 数字排在网格之前 = 画在网格之上index 0 最上),否则细线会从
# 数字上横穿过去,像划掉了一样。
shell = stack(ids, name, body, list(extra_decor) + paper_grid(),
width=W, height=H, fill=solid(fill))
shell["content"] = body
return shell
def col(name, children, *, gap=16, width="fill_container",
height="fit_content", align="start", fill=None, **props):
"""纵向容器。`fill=None` 才是结构层;一旦有 fill 它就是一张卡面了。"""
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
def row(name, children, *, gap=40, align="start", width="fill_container",
height="fit_content", fill=None, **props):
node = frame(ids, name, width=width, height=height, layout="horizontal",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
# 左侧色条的宽度。10px 在 1920 宽的版面上刚好读得出是「一条标记」而不是
# 「卡片描边画歪了」。
RAIL = 10
def knowledge_card(children, *, gap=16, height="fit_content", padding=(36, 40),
width="fill_container"):
"""带左侧色条的知识点卡。
色条是一个 fill_container 高的兄弟矩形,不是 per-side 描边:描边会跟着
圆角走一圈,在 20px 圆角上那一段弧读起来像描边没画完。外层 clipContent
把直角色条切进圆角里,得到的才是「书页边上贴的那条标签」。
"""
inner = col("卡内容", children, gap=gap, padding=[padding[0], padding[1]])
return row("知识点卡", [
rect(ids, "知识点色条", width=RAIL, height="fill_container",
fill=solid("$c-accent")),
inner,
], gap=0, align="stretch", width=width, height=height, padding=[0, 0],
cornerRadius=20, fill=solid("$c-surface"), clipContent=True)
def eyebrow(label):
"""讲次标签。整套课件唯一的胶囊,交代「这是第几讲」。"""
node = frame(ids, "标签", width="fit_content", height="fit_content",
layout="horizontal", padding=[10, 22], gap=0,
cornerRadius=999, alignItems="center",
justifyContent="center", fill=solid("$c-accent-soft"))
node["children"] = [
text(ids, "标签文字", label, 26, 600, "$c-accent", family=CJK,
width="fit_content", growth="auto", line_height=1.4,
spacing=1.0),
]
return node
def accent_bar(width=150, height=10):
return rect(ids, "强调条", width=width, height=height, cornerRadius=999,
fill=solid("$c-accent"))
def numbered(no, size, *, filled=True):
"""序号圆。白字压在墨绿上是全页对比度最高的一处,是眼睛的落点。"""
if filled:
fill, ink, edge = solid("$c-accent"), "#FFFFFF", None
else:
fill, ink, edge = solid("$c-surface"), "$c-accent", stroke("$c-accent", 3)
node = frame(ids, f"序号 {no}", width=size, height=size,
layout="horizontal", gap=0, alignItems="center",
justifyContent="center", cornerRadius=size // 2, fill=fill,
**({"stroke": edge} if edge else {}))
node["children"] = [
text(ids, "序号数字", str(no), round(size * 0.46), 700, ink,
family=NUM, width="fit_content", growth="auto", line_height=1.0),
]
return node
def centered_body(name, child):
"""把内容块撑到页标题以下的整块空间里再垂直居中。
页标题每页固定在同一高度(重复)是课件读起来整齐的前提;正文块又不该
被拉长到失真。两个诉求靠这一层透明容器同时满足。
"""
return col(name, [child], gap=0, height="fill_container",
justifyContent="center")
def page_head(title, subtitle=None, *, size=76):
kids = [text(ids, "页标题", title, size, 700, "$c-ink", family=CJK,
line_height=1.12, spacing=-2)]
if subtitle:
kids.append(text(ids, "页副标题", subtitle, 30, 400, "$c-muted",
family=CJK, line_height=1.45))
return col("页头", kids, gap=18)
# ------------------------------------------------------------------ 01 封面
def cover():
lede = col("课程标题", [
text(ids, "课名", "函数的单调性\n与极值判定", 116, 700, "$c-ink",
family=CJK, line_height=1.08, spacing=-3),
accent_bar(),
text(ids, "课程说明", "这一讲我们只解决一个问题:拿到一个函数,怎么"
"判断它在哪段往上走。", 30, 400, "$c-muted",
family=CJK, line_height=1.45),
], gap=36)
meta = row("讲师信息", [
text(ids, "讲师", "主讲 · 林望舒", 28, 500, "$c-ink", family=CJK,
width="fit_content", growth="auto"),
text(ids, "学期", "高等数学(上)· 2026 秋", 28, 400, "$c-muted",
family=CJK, width="fit_content", growth="auto"),
], gap=32, align="center", justifyContent="space_between")
s = slide("01 课程封面", gap=48, justify="space_between")
s["content"]["children"] = [eyebrow("第 07 讲"), lede, meta]
return s
# ------------------------------------------------------------------ 02 目标
OBJECTIVES = [
("说得出定义", "用导数的符号说明单调递增、递减到底在讲什么。"),
("判得出区间", "给定一个可导函数,能划出它的单调区间。"),
("找得到极值", "会用一阶导数的符号变化确定极大值与极小值。"),
]
def objectives():
items = []
for index, (title, desc) in enumerate(OBJECTIVES, 1):
items.append(knowledge_card([
row(f"目标 {index}", [
numbered(index, 64),
col("目标文案", [
text(ids, "目标标题", title, 40, 600, "$c-ink",
family=CJK, line_height=1.25),
text(ids, "目标说明", desc, 28, 400, "$c-muted",
family=CJK, line_height=1.5),
], gap=12),
], gap=28, align="start"),
], gap=0))
s = slide("02 学习目标", gap=56, extra_decor=[ghost_number("02", 1350, 40)])
s["content"]["children"] = [
page_head("这节课下课时,你应该能——",
"三条,都要能自己动手做出来,不是听懂就算。"),
centered_body("目标区", col("目标列表", items, gap=24)),
]
return s
# ------------------------------------------------------------------ 03 讲解
def concept():
body = knowledge_card([
col("定义组", [
text(ids, "定义标题", "单调性的判定定理", 40, 600, "$c-ink",
family=CJK, line_height=1.25),
text(ids, "定义正文",
"设函数 f(x) 在区间 (a, b) 内可导。若在该区间内恒有 "
"f'(x) > 0则 f(x) 在 (a, b) 上单调递增;若恒有 "
"f'(x) < 0则单调递减。",
30, 400, "$c-ink", family=CJK, line_height=1.6),
], gap=20),
col("追问组", [
rect(ids, "正文分隔线", width="fill_container", height=2,
fill=solid("$c-border")),
text(ids, "提示标题", "为什么成立", 30, 600, "$c-accent",
family=CJK, line_height=1.3),
text(ids, "提示正文",
"导数是切线的斜率。斜率一直为正,意味着曲线上任意两点连线"
"都在往右上方走——这正是「递增」的几何说法。",
28, 400, "$c-muted", family=CJK, line_height=1.6),
], gap=20),
], gap=40, height="fill_container", padding=(44, 44))
# 配图位用 frame 而不是 rectangleframe 会递归渲染子节点,里面那两行
# 提示才显示得出来frame 同时是合法的图片拖放目标,把函数图像拖上去
# 就直接替换掉这个占位。
figure = col("配图占位", [
text(ids, "配图提示", "把函数图像拖到这里", 30, 600, "$c-accent",
family=CJK, width="fit_content", growth="auto",
line_height=1.3),
text(ids, "配图说明", "建议画出 f(x) 与 f'(x) 的符号对照",
26, 400, "$c-muted", family=CJK, width="fit_content",
growth="auto", line_height=1.45),
], gap=12, width=700, height="fill_container", padding=[0, 48],
align="center", justifyContent="center", cornerRadius=20,
fill=solid("$c-accent-soft"))
s = slide("03 概念讲解", gap=56, extra_decor=[ghost_number("03", 1350, 40)])
s["content"]["children"] = [
page_head("导数的符号,决定曲线的走向"),
centered_body("图文区", row("图文两栏", [body, figure], gap=48,
align="stretch", height=560)),
]
return s
# ------------------------------------------------------------------ 04 例题
STEPS = [
("求导", "f'(x) = 3x² 6x = 3x(x 2)"),
("找驻点", "令 f'(x) = 0得 x = 0 与 x = 2"),
("判符号", "x < 0 与 x > 2 时 f'(x) > 00 < x < 2 时 f'(x) < 0"),
("下结论", "x = 0 处取极大值 f(0) = 1x = 2 处取极小值 f(2) = 3"),
]
def worked_example():
prompt = col("题干", [
text(ids, "题干标签", "例题", 26, 600, "$c-accent", family=CJK,
width="fit_content", growth="auto", line_height=1.3,
spacing=1.0),
text(ids, "题干正文",
"求函数 f(x) = x³ 3x² + 1 的单调区间与极值。",
34, 600, "$c-ink", family=CJK, line_height=1.35),
], gap=12, padding=[32, 36], cornerRadius=16, fill=solid("$c-accent-soft"))
steps = []
for index, (title, formula) in enumerate(STEPS, 1):
steps.append(row(f"步骤 {index}", [
numbered(index, 56),
col("步骤文案", [
text(ids, "步骤标题", title, 30, 600, "$c-ink", family=CJK,
line_height=1.3),
text(ids, "步骤算式", formula, 28, 400, "$c-muted",
family=NUM, line_height=1.5),
], gap=8),
], gap=24, align="start"))
# 题干与解答装进同一张卡:例题在课件里是一个整体,拆成两块平级容器既
# 削弱了「这是一道题」的读法,也让页面上出现三个圆角不一致的兄弟
# mixed-sibling-corner-radius 会照实报出来)。
exam = knowledge_card([
prompt,
col("解答步骤", steps, gap=24),
], gap=32, padding=(40, 44))
s = slide("04 例题", gap=56, extra_decor=[ghost_number("04", 1350, 40)])
s["content"]["children"] = [
page_head("按这四步走,一步都不要跳"),
centered_body("例题区", exam),
]
return s
# ------------------------------------------------------------------ 05 对比
COMPARISON = [
("看的是什么", "f'(x) 在整个区间上的符号", "f'(x) 在某一点两侧的符号变化"),
("回答的问题", "这段曲线往上还是往下", "这一点是不是拐上去/拐下来的地方"),
("容易错在哪", "漏掉不可导的点也要单独讨论",
"驻点不一定是极值点,要看符号是否真的变了"),
]
# 表格分隔线用 PenStroke 的 per-side 厚度:只画底边,四条边全画会把表格
# 变成田字格,行与行的关系反而看不出来。最后一行不画,免得表格底部多出
# 一条没有下文的线。
def table_row(label, left, right, *, last=False):
cells = [
col("行标题", [
text(ids, "行标题文字", label, 27, 600, "$c-muted", family=CJK,
line_height=1.4),
], gap=0, width=260),
col("左列内容", [
text(ids, "左列文字", left, 28, 400, "$c-ink", family=CJK,
line_height=1.5),
], gap=0),
col("右列内容", [
text(ids, "右列文字", right, 28, 400, "$c-ink", family=CJK,
line_height=1.5),
], gap=0),
]
props = {"padding": [26, 32]}
if not last:
props["stroke"] = {"thickness": {"bottom": 2},
"fill": solid("$c-border")}
return row("对比行", cells, gap=40, align="start", **props)
def comparison():
header = row("表头", [
col("表头行标题", [
text(ids, "表头留白", " ", 27, 600, "$c-muted", family=CJK,
line_height=1.4),
], gap=0, width=260),
col("表头左列", [
text(ids, "表头左列文字", "单调性", 34, 700, "$c-accent",
family=CJK, line_height=1.3),
], gap=0),
col("表头右列", [
text(ids, "表头右列文字", "极值", 34, 700, "$c-accent",
family=CJK, line_height=1.3),
], gap=0),
], gap=40, align="start", padding=[26, 32],
stroke={"thickness": {"bottom": 3}, "fill": solid("$c-accent")})
# 对比表刻意不带左侧色条:色条在这套里是「知识点」的标记,一张对照表
# 不是知识点。装饰语言一旦到处贴,就不再是语言了。
table = col("对比表", [header] + [
table_row(*entry, last=index == len(COMPARISON) - 1)
for index, entry in enumerate(COMPARISON)
], gap=0, cornerRadius=20, fill=solid("$c-surface"), clipContent=True)
s = slide("05 对比", gap=56, extra_decor=[ghost_number("05", 1350, 40)])
s["content"]["children"] = [
page_head("两个概念,别再混着用",
"单调性说的是一段,极值说的是一点。"),
centered_body("对比区", table),
]
return s
# ------------------------------------------------------------------ 06 小结
TAKEAWAYS = [
"先求导,再找驻点,最后判符号——顺序不能换。",
"驻点只是候选,符号真的变了才是极值点。",
"不可导的点也要单独拿出来讨论。",
]
HOMEWORK = [
("课本 P128", "习题 3.4 第 1、3、5 题"),
("补充题", "讨论 f(x) = x·e^(x) 的单调区间与极值"),
("下次课前", "预习凹凸性与拐点P131P136"),
]
def bullet_dot(size=14, line_size=28, line_height=1.55):
"""项目符号。包一层只有上内距的容器,把圆心压到首行的光学中线上。
`align="start"` 会把圆点顶边对齐到文本盒顶边,而文本首行的中线在
`fontSize * lineHeight / 2` 处 —— 直接并排会让每个圆点浮在字的上方。
"""
lift = round(line_size * line_height / 2 - size / 2)
return col("条目圆点位", [
rect(ids, "条目圆点", width=size, height=size,
cornerRadius=size // 2, fill=solid("$c-accent")),
], gap=0, width="fit_content", padding=[lift, 0, 0, 0])
def summary():
left = knowledge_card([
text(ids, "小结标题", "本讲小结", 44, 700, "$c-ink", family=CJK,
line_height=1.2),
col("小结条目", [
row("小结条", [bullet_dot(),
text(ids, "条目文字", line, 28, 400, "$c-ink", family=CJK,
line_height=1.55)],
gap=20, align="start")
for line in TAKEAWAYS
], gap=20),
], gap=28, height="fill_container", padding=(44, 44))
right = col("课后作业", [
text(ids, "作业标题", "课后作业", 44, 700, "$c-ink", family=CJK,
line_height=1.2),
col("作业条目", [
col("作业条", [
text(ids, "作业出处", source, 26, 600, "$c-accent",
family=CJK, line_height=1.3),
text(ids, "作业内容", detail, 28, 400, "$c-muted",
family=CJK, line_height=1.5),
], gap=8)
for source, detail in HOMEWORK
], gap=24),
], gap=28, width=680, height="fill_container", padding=[44, 44],
cornerRadius=20, fill=solid("$c-accent-soft"))
s = slide("06 小结与作业", gap=56,
extra_decor=[ghost_number("06", 1350, 40)])
s["content"]["children"] = [
page_head("带走这三句话就够了"),
centered_body("小结区", row("小结两栏", [left, right], gap=48,
align="stretch", height=490)),
]
return s
# 顶层 frame 必须显式写 x/y缺省时每帧都落在原点六页会叠成一页另外
# 五页藏在下面2026-08-02 实录)。纵向 gap 比横向多留 240是给画布上固定
# 在帧顶部的帧名标签让位——标签是屏幕空间的,不随缩放变小。
BOARD_GAP_X = 120
BOARD_GAP_Y = BOARD_GAP_X + 240
BOARDS_PER_ROW = 3
def build():
boards = [cover(), objectives(), concept(), worked_example(),
comparison(), summary()]
for index, board in enumerate(boards):
board.pop("content", None)
board["x"] = (index % BOARDS_PER_ROW) * (W + BOARD_GAP_X)
board["y"] = (index // BOARDS_PER_ROW) * (H + BOARD_GAP_Y)
return boards
# 对比度WCAG 相对亮度比,投影场景自设门槛 4.5,远高于 op-design-lint 的 2.5
# c-ink on c-bg 14.25 c-ink on c-surface 16.53
# c-muted on c-bg 5.46 c-muted on c-surface 6.33
# c-accent on c-bg 5.56 c-accent on c-surface 6.45
# c-accent on c-accent-soft 5.11 c-muted on c-accent-soft 5.01 ← 最低
# #FFFFFF on c-accent 6.45(序号圆里的白字)
# 换主色时先量 c-muted on c-accent-soft浅色 deck 的下限总是出在
# 「弱化文字压在弱化底色上」这一对,不是出在标题上。
# 网格线与 ghost 页码不参与:前者是 8% 的装饰兄弟,后者是「满色 + 6% 图层
# 透明度」,两者都不承担阅读。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "课件 deck · 浅色 16:9")