feat(editor): grow the template library to five deck styles and a card tier

Adds a knowledge-card scene (vertical and square social cards) and
four presentation styles beside the original warm-white deck: a dark
pitch deck, a paper-white lecture deck, a minimal keynote and a
gradient-tech deck with glassmorphism built from opaque composited
stops — the linter drops alpha channels, and a truly translucent card
would exempt its text from contrast checking forever. Every palette
pair ships with measured WCAG ratios baked into the generators, and
low-contrast probe documents prove the audit actually fires on each
template family.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-08-05 00:47:50 +08:00
parent dfbc80a8d7
commit cc9b7889e2
64 changed files with 11680 additions and 3 deletions

View file

@ -45,6 +45,30 @@ frames = 6
frame_width = 1920
frame_height = 1080
[[template]]
id = "knowledge-card-vertical"
scene = "card"
title_key = "sceneTemplate.item.knowledgeCardVertical.title"
title_fallback = "知识卡片 · 竖版"
summary_key = "sceneTemplate.item.knowledgeCardVertical.summary"
summary_fallback = "3:4 单张图文卡,标题、四条要点和署名条,换掉文案就能发小红书。"
tags = ["卡片", "知识", "小红书", "图文", "card", "note"]
frames = 1
frame_width = 1080
frame_height = 1440
[[template]]
id = "knowledge-card-square"
scene = "card"
title_key = "sceneTemplate.item.knowledgeCardSquare.title"
title_fallback = "知识卡片 · 方版"
summary_key = "sceneTemplate.item.knowledgeCardSquare.summary"
summary_fallback = "1:1 方形卡,同一套版式的紧凑版,适合公众号头图与朋友圈。"
tags = ["卡片", "知识", "方形", "公众号", "card", "square"]
frames = 1
frame_width = 1080
frame_height = 1080
[[template]]
id = "before-after"
scene = "comparison"
@ -56,3 +80,51 @@ tags = ["对比", "前后", "复盘", "comparison", "before after"]
frames = 1
frame_width = 1600
frame_height = 900
[[template]]
id = "pitch-deck-dark"
scene = "slides"
title_key = "sceneTemplate.item.pitchDeckDark.title"
title_fallback = "路演 deck · 深色"
summary_key = "sceneTemplate.item.pitchDeckDark.summary"
summary_fallback = "封面、问题、方案、数据、里程碑和联系页,深底大字,适合融资路演与产品发布。"
tags = ["路演", "融资", "创业", "深色", "PPT", "pitch", "deck", "startup", "dark"]
frames = 6
frame_width = 1920
frame_height = 1080
[[template]]
id = "lecture-deck-light"
scene = "slides"
title_key = "sceneTemplate.item.lectureDeckLight.title"
title_fallback = "课件 deck · 浅色"
summary_key = "sceneTemplate.item.lectureDeckLight.summary"
summary_fallback = "课程封面、学习目标、概念讲解、例题、对比表和小结作业,纸白底耐看,适合上课投影。"
tags = ["课件", "教学", "讲课", "教育", "PPT", "lecture", "teaching", "course", "light"]
frames = 6
frame_width = 1920
frame_height = 1080
[[template]]
id = "minimal-keynote"
scene = "slides"
title_key = "sceneTemplate.item.minimalKeynote.title"
title_fallback = "极简 Keynote"
summary_key = "sceneTemplate.item.minimalKeynote.summary"
summary_fallback = "纯白留白、超大字号、一页一个意思,八页里没有一张卡片,适合发布会与主题演讲。"
tags = ["极简", "留白", "发布会", "演讲", "keynote", "minimal", "PPT", "slides"]
frames = 8
frame_width = 1920
frame_height = 1080
[[template]]
id = "gradient-tech"
scene = "slides"
title_key = "sceneTemplate.item.gradientTech.title"
title_fallback = "渐变科技风"
summary_key = "sceneTemplate.item.gradientTech.summary"
summary_fallback = "深色渐变底加玻璃拟态卡,含架构、性能对比与客户墙,适合开发者产品发布。"
tags = ["渐变", "科技", "玻璃", "深色", "开发者", "gradient", "tech", "glass", "PPT"]
frames = 6
frame_width = 1920
frame_height = 1080

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,617 @@
{
"version": "1.0.0",
"name": "知识卡片 · 方版 1:1",
"variables": {
"c-bg": {
"type": "color",
"value": "#FFF7F0"
},
"c-surface": {
"type": "color",
"value": "#FFFFFF"
},
"c-ink": {
"type": "color",
"value": "#241A12"
},
"c-muted": {
"type": "color",
"value": "#7A6455"
},
"c-accent": {
"type": "color",
"value": "#C8502B"
},
"c-accent-soft": {
"type": "color",
"value": "#FAE3D8"
},
"c-border": {
"type": "color",
"value": "#EDDCD0"
}
},
"children": [
{
"type": "frame",
"id": "f74",
"name": "知识卡片 · 方版",
"width": 1080,
"height": 1080,
"layout": "vertical",
"padding": [
88,
84
],
"gap": 0,
"justifyContent": "space_between",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "frame",
"id": "f46",
"name": "卡头",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 26,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f41",
"name": "标签",
"width": "fit_content",
"height": "fit_content",
"layout": "horizontal",
"padding": [
12,
24
],
"gap": 0,
"cornerRadius": 999,
"alignItems": "center",
"justifyContent": "center",
"fill": [
{
"type": "solid",
"color": "$c-accent-soft"
}
],
"children": [
{
"type": "text",
"id": "t42",
"name": "标签文字",
"content": "学习方法",
"fontFamily": "Noto Sans SC",
"fontSize": 26,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"lineHeight": 1.4,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "text",
"id": "t43",
"name": "主标题",
"content": "读完就忘?\n先补上这一步",
"fontFamily": "Noto Sans SC",
"fontSize": 76,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.15,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "rectangle",
"id": "r44",
"name": "强调短线",
"width": 112,
"height": 10,
"cornerRadius": 5,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
]
},
{
"type": "text",
"id": "t45",
"name": "副标题",
"content": "三个动作,把书里的东西真正带走。",
"fontFamily": "Noto Sans SC",
"fontSize": 28,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "f65",
"name": "要点列表",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 24,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f47",
"name": "要点 1",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 22,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f48",
"name": "序号 1",
"width": 52,
"height": 52,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 26,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"children": [
{
"type": "text",
"id": "t49",
"name": "序号数字",
"content": "1",
"fontFamily": "Inter",
"fontSize": 26,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "#FFFFFF"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f52",
"name": "要点文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 10,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t50",
"name": "要点标题",
"content": "先提问,再翻开",
"fontFamily": "Noto Sans SC",
"fontSize": 34,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t51",
"name": "要点说明",
"content": "带着问题进去,信息才有地方落。",
"fontFamily": "Noto Sans SC",
"fontSize": 26,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
},
{
"type": "frame",
"id": "f53",
"name": "要点 2",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 22,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f54",
"name": "序号 2",
"width": 52,
"height": 52,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 26,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"children": [
{
"type": "text",
"id": "t55",
"name": "序号数字",
"content": "2",
"fontFamily": "Inter",
"fontSize": 26,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "#FFFFFF"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f58",
"name": "要点文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 10,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t56",
"name": "要点标题",
"content": "合上书,先复述",
"fontFamily": "Noto Sans SC",
"fontSize": 34,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t57",
"name": "要点说明",
"content": "讲不出来的地方,就是还没懂的地方。",
"fontFamily": "Noto Sans SC",
"fontSize": 26,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
},
{
"type": "frame",
"id": "f59",
"name": "要点 3",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 22,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f60",
"name": "序号 3",
"width": 52,
"height": 52,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 26,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"children": [
{
"type": "text",
"id": "t61",
"name": "序号数字",
"content": "3",
"fontFamily": "Inter",
"fontSize": 26,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "#FFFFFF"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f64",
"name": "要点文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 10,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t62",
"name": "要点标题",
"content": "两天之内用一次",
"fontFamily": "Noto Sans SC",
"fontSize": 34,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t63",
"name": "要点说明",
"content": "用过一次,才算真的学过。",
"fontFamily": "Noto Sans SC",
"fontSize": 26,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "f73",
"name": "署名",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 22,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "rectangle",
"id": "r72",
"name": "分隔线",
"width": "fill_container",
"height": 2,
"fill": [
{
"type": "solid",
"color": "$c-border"
}
]
},
{
"type": "frame",
"id": "f68",
"name": "署名行",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 22,
"alignItems": "center",
"fill": [],
"children": [
{
"type": "frame",
"id": "f66",
"name": "头像占位",
"width": 76,
"height": 76,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 38,
"fill": [
{
"type": "solid",
"color": "$c-accent-soft"
}
],
"children": [
{
"type": "text",
"id": "t67",
"name": "头像文字",
"content": "笔",
"fontFamily": "Noto Sans SC",
"fontSize": 32,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f71",
"name": "署名文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 6,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t69",
"name": "账号名",
"content": "@ 你的账号名",
"fontFamily": "Noto Sans SC",
"fontSize": 28,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t70",
"name": "一句话简介",
"content": "每周更新一条可落地的学习方法",
"fontFamily": "Noto Sans SC",
"fontSize": 23,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
}
]
}
],
"x": 0,
"y": 0
}
]
}

View file

@ -0,0 +1,719 @@
{
"version": "1.0.0",
"name": "知识卡片 · 竖版 3:4",
"variables": {
"c-bg": {
"type": "color",
"value": "#FFF7F0"
},
"c-surface": {
"type": "color",
"value": "#FFFFFF"
},
"c-ink": {
"type": "color",
"value": "#241A12"
},
"c-muted": {
"type": "color",
"value": "#7A6455"
},
"c-accent": {
"type": "color",
"value": "#C8502B"
},
"c-accent-soft": {
"type": "color",
"value": "#FAE3D8"
},
"c-border": {
"type": "color",
"value": "#EDDCD0"
}
},
"children": [
{
"type": "frame",
"id": "f40",
"name": "知识卡片 · 竖版",
"width": 1080,
"height": 1440,
"layout": "vertical",
"padding": [
96,
88
],
"gap": 0,
"justifyContent": "space_between",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "frame",
"id": "f6",
"name": "卡头",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 32,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f1",
"name": "标签",
"width": "fit_content",
"height": "fit_content",
"layout": "horizontal",
"padding": [
12,
24
],
"gap": 0,
"cornerRadius": 999,
"alignItems": "center",
"justifyContent": "center",
"fill": [
{
"type": "solid",
"color": "$c-accent-soft"
}
],
"children": [
{
"type": "text",
"id": "t2",
"name": "标签文字",
"content": "学习方法 · 01",
"fontFamily": "Noto Sans SC",
"fontSize": 26,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"lineHeight": 1.4,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "text",
"id": "t3",
"name": "主标题",
"content": "为什么你读了很多书\n却什么都记不住",
"fontFamily": "Noto Sans SC",
"fontSize": 88,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.15,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "rectangle",
"id": "r4",
"name": "强调短线",
"width": 132,
"height": 12,
"cornerRadius": 6,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
]
},
{
"type": "text",
"id": "t5",
"name": "副标题",
"content": "不是记性差,是你少做了这一步。",
"fontFamily": "Noto Sans SC",
"fontSize": 30,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "f31",
"name": "要点列表",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 28,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f7",
"name": "要点 1",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 24,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f8",
"name": "序号 1",
"width": 56,
"height": 56,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 28,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"children": [
{
"type": "text",
"id": "t9",
"name": "序号数字",
"content": "1",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "#FFFFFF"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f12",
"name": "要点文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 10,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t10",
"name": "要点标题",
"content": "先提问,再翻开",
"fontFamily": "Noto Sans SC",
"fontSize": 36,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t11",
"name": "要点说明",
"content": "带着「我想解决什么」进去,信息才有地方落。",
"fontFamily": "Noto Sans SC",
"fontSize": 27,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
},
{
"type": "frame",
"id": "f13",
"name": "要点 2",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 24,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f14",
"name": "序号 2",
"width": 56,
"height": 56,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 28,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"children": [
{
"type": "text",
"id": "t15",
"name": "序号数字",
"content": "2",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "#FFFFFF"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f18",
"name": "要点文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 10,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t16",
"name": "要点标题",
"content": "合上书,先复述",
"fontFamily": "Noto Sans SC",
"fontSize": 36,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t17",
"name": "要点说明",
"content": "读完立刻用自己的话讲一遍,卡壳的地方就是没懂的地方。",
"fontFamily": "Noto Sans SC",
"fontSize": 27,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
},
{
"type": "frame",
"id": "f19",
"name": "要点 3",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 24,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f20",
"name": "序号 3",
"width": 56,
"height": 56,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 28,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"children": [
{
"type": "text",
"id": "t21",
"name": "序号数字",
"content": "3",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "#FFFFFF"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f24",
"name": "要点文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 10,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t22",
"name": "要点标题",
"content": "一次只带走三条",
"fontFamily": "Noto Sans SC",
"fontSize": 36,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t23",
"name": "要点说明",
"content": "贪多必忘。记住三个能用的点,比划满一整本有用。",
"fontFamily": "Noto Sans SC",
"fontSize": 27,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
},
{
"type": "frame",
"id": "f25",
"name": "要点 4",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 24,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "frame",
"id": "f26",
"name": "序号 4",
"width": 56,
"height": 56,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 28,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"children": [
{
"type": "text",
"id": "t27",
"name": "序号数字",
"content": "4",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "#FFFFFF"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f30",
"name": "要点文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 10,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t28",
"name": "要点标题",
"content": "两天之内用一次",
"fontFamily": "Noto Sans SC",
"fontSize": 36,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t29",
"name": "要点说明",
"content": "写进笔记、讲给朋友、或者直接做一遍,用过才算学过。",
"fontFamily": "Noto Sans SC",
"fontSize": 27,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "f39",
"name": "署名",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 24,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "rectangle",
"id": "r38",
"name": "分隔线",
"width": "fill_container",
"height": 2,
"fill": [
{
"type": "solid",
"color": "$c-border"
}
]
},
{
"type": "frame",
"id": "f34",
"name": "署名行",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 24,
"alignItems": "center",
"fill": [],
"children": [
{
"type": "frame",
"id": "f32",
"name": "头像占位",
"width": 88,
"height": 88,
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"cornerRadius": 44,
"fill": [
{
"type": "solid",
"color": "$c-accent-soft"
}
],
"children": [
{
"type": "text",
"id": "t33",
"name": "头像文字",
"content": "笔",
"fontFamily": "Noto Sans SC",
"fontSize": 37,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"lineHeight": 1.0,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
},
{
"type": "frame",
"id": "f37",
"name": "署名文案",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 6,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t35",
"name": "账号名",
"content": "@ 你的账号名",
"fontFamily": "Noto Sans SC",
"fontSize": 30,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t36",
"name": "一句话简介",
"content": "每周更新一条可落地的学习方法",
"fontFamily": "Noto Sans SC",
"fontSize": 24,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
}
]
}
]
}
],
"x": 0,
"y": 0
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,641 @@
{
"version": "1.0.0",
"name": "极简 Keynote · 16:9",
"variables": {
"c-bg": {
"type": "color",
"value": "#FFFFFF"
},
"c-ink": {
"type": "color",
"value": "#0A0A0A"
},
"c-muted": {
"type": "color",
"value": "#6B6B6B"
},
"c-accent": {
"type": "color",
"value": "#C7340F"
}
},
"children": [
{
"type": "frame",
"id": "f8",
"name": "01 封面",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 48,
"justifyContent": "space_between",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "frame",
"id": "f4",
"name": "主张",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 40,
"alignItems": "start",
"fill": [],
"children": [
{
"type": "text",
"id": "t1",
"name": "主标题",
"content": "把复杂\n留在里面",
"fontFamily": "Noto Sans SC",
"fontSize": 130,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.08,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "rectangle",
"id": "r2",
"name": "强调短线",
"width": 120,
"height": 8,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
]
},
{
"type": "text",
"id": "t3",
"name": "副标题",
"content": "第七代产品,今天发布。",
"fontFamily": "Noto Sans SC",
"fontSize": 32,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.45,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "f7",
"name": "落款",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"gap": 28,
"alignItems": "center",
"fill": [],
"justifyContent": "space_between",
"children": [
{
"type": "text",
"id": "t5",
"name": "场合",
"content": "2026 秋季发布会",
"fontFamily": "Noto Sans SC",
"fontSize": 26,
"fontWeight": 500,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
},
{
"type": "text",
"id": "t6",
"name": "日期",
"content": "2026.10.08",
"fontFamily": "Inter",
"fontSize": 26,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.6,
"letterSpacing": 0,
"textGrowth": "auto",
"width": "fit_content"
}
]
}
],
"x": 0,
"y": 0
},
{
"type": "frame",
"id": "f9",
"name": "02 论点",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 44,
"justifyContent": "center",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "rectangle",
"id": "r10",
"name": "强调点",
"width": 18,
"height": 18,
"cornerRadius": 9,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
]
},
{
"type": "text",
"id": "t11",
"name": "论点",
"content": "我们删掉了三十七个按钮。",
"fontFamily": "Noto Sans SC",
"fontSize": 76,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.15,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
],
"x": 2040,
"y": 0
},
{
"type": "frame",
"id": "f12",
"name": "03 词 · 少",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 36,
"justifyContent": "center",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "text",
"id": "t13",
"name": "大词",
"content": "少",
"fontFamily": "Noto Sans SC",
"fontSize": 200,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.05,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t14",
"name": "注解",
"content": "从三十七个按钮,到三个。",
"fontFamily": "Noto Sans SC",
"fontSize": 30,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
],
"x": 4080,
"y": 0
},
{
"type": "frame",
"id": "f15",
"name": "04 词 · 轻",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 36,
"justifyContent": "center",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "text",
"id": "t16",
"name": "大词",
"content": "轻",
"fontFamily": "Noto Sans SC",
"fontSize": 200,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.05,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t17",
"name": "注解",
"content": "整机一点零九公斤,比上一代少了两百四十克。",
"fontFamily": "Noto Sans SC",
"fontSize": 30,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
],
"x": 0,
"y": 1440
},
{
"type": "frame",
"id": "f18",
"name": "05 词 · 久",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 36,
"justifyContent": "center",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "text",
"id": "t19",
"name": "大词",
"content": "久",
"fontFamily": "Noto Sans SC",
"fontSize": 200,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.05,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t20",
"name": "注解",
"content": "一次充电,用满两天。",
"fontFamily": "Noto Sans SC",
"fontSize": 30,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
],
"x": 2040,
"y": 1440
},
{
"type": "frame",
"id": "f21",
"name": "06 引用",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 32,
"justifyContent": "center",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "text",
"id": "t22",
"name": "引号",
"content": "“",
"fontFamily": "Inter",
"fontSize": 160,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
],
"lineHeight": 0.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t23",
"name": "引文",
"content": "最好的设计,是让人忘了它存在。",
"fontFamily": "Noto Sans SC",
"fontSize": 54,
"fontWeight": 600,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.3,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t24",
"name": "出处",
"content": "— 产品负责人 沈亦文",
"fontFamily": "Noto Sans SC",
"fontSize": 28,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
],
"x": 4080,
"y": 1440
},
{
"type": "frame",
"id": "f25",
"name": "07 数据",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 32,
"justifyContent": "center",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "text",
"id": "t26",
"name": "大数字",
"content": "0.4 秒",
"fontFamily": "Inter",
"fontSize": 200,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.05,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t27",
"name": "注解",
"content": "从按下到出图的全部时间,上一代是 2.6 秒。",
"fontFamily": "Noto Sans SC",
"fontSize": 32,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
],
"x": 0,
"y": 2880
},
{
"type": "frame",
"id": "f28",
"name": "08 结尾",
"width": 1920,
"height": 1080,
"layout": "vertical",
"padding": [
160,
160
],
"gap": 40,
"justifyContent": "center",
"alignItems": "start",
"fill": [
{
"type": "solid",
"color": "$c-bg"
}
],
"clipContent": true,
"children": [
{
"type": "rectangle",
"id": "r29",
"name": "强调短线",
"width": 120,
"height": 8,
"fill": [
{
"type": "solid",
"color": "$c-accent"
}
]
},
{
"type": "text",
"id": "t30",
"name": "结语",
"content": "今天开始发货。",
"fontFamily": "Noto Sans SC",
"fontSize": 96,
"fontWeight": 700,
"fill": [
{
"type": "solid",
"color": "$c-ink"
}
],
"lineHeight": 1.12,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
},
{
"type": "text",
"id": "t31",
"name": "补充",
"content": "全国门店同步开售,官网可预约体验。",
"fontFamily": "Noto Sans SC",
"fontSize": 30,
"fontWeight": 400,
"fill": [
{
"type": "solid",
"color": "$c-muted"
}
],
"lineHeight": 1.5,
"letterSpacing": 0,
"textGrowth": "fixed-width",
"width": "fill_container"
}
],
"x": 2040,
"y": 2880
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -89,9 +89,15 @@ pub fn scenario_default_export_format(scenario: Option<TemplateScene>) -> Option
// A deck is delivered as a slide-per-page PDF — see
// `op_host_services::export_pdf::export_deck_pdf`.
Some(TemplateScene::Slides) => Some(ExportFormat::Pdf),
// A card is delivered as a PNG, but so is a tutorial and a carousel,
// and the user's own last choice (WebP for a lighter upload, say) is
// as valid. Presetting here would silently overwrite that on every
// export; the deck is the only scenario with a format its delivery
// actually depends on.
Some(TemplateScene::Tutorial)
| Some(TemplateScene::Comparison)
| Some(TemplateScene::Carousel)
| Some(TemplateScene::Card)
| None => None,
}
}

View file

@ -27,6 +27,14 @@ const SCREENSHOT_TUTORIAL_OP: &str =
const KNOWLEDGE_CAROUSEL_OP: &str = include_str!("../assets/scene_templates/knowledge-carousel.op");
const BEFORE_AFTER_OP: &str = include_str!("../assets/scene_templates/before-after.op");
const SLIDE_DECK_OP: &str = include_str!("../assets/scene_templates/slide-deck.op");
const KNOWLEDGE_CARD_VERTICAL_OP: &str =
include_str!("../assets/scene_templates/knowledge-card-vertical.op");
const KNOWLEDGE_CARD_SQUARE_OP: &str =
include_str!("../assets/scene_templates/knowledge-card-square.op");
const PITCH_DECK_DARK_OP: &str = include_str!("../assets/scene_templates/pitch-deck-dark.op");
const LECTURE_DECK_LIGHT_OP: &str = include_str!("../assets/scene_templates/lecture-deck-light.op");
const MINIMAL_KEYNOTE_OP: &str = include_str!("../assets/scene_templates/minimal-keynote.op");
const GRADIENT_TECH_OP: &str = include_str!("../assets/scene_templates/gradient-tech.op");
/// Return the embedded document JSON for a template id.
///
@ -40,6 +48,12 @@ pub fn scene_template_document(template_id: &str) -> Option<&'static str> {
"knowledge-carousel" => Some(KNOWLEDGE_CAROUSEL_OP),
"before-after" => Some(BEFORE_AFTER_OP),
"slide-deck" => Some(SLIDE_DECK_OP),
"knowledge-card-vertical" => Some(KNOWLEDGE_CARD_VERTICAL_OP),
"knowledge-card-square" => Some(KNOWLEDGE_CARD_SQUARE_OP),
"pitch-deck-dark" => Some(PITCH_DECK_DARK_OP),
"lecture-deck-light" => Some(LECTURE_DECK_LIGHT_OP),
"minimal-keynote" => Some(MINIMAL_KEYNOTE_OP),
"gradient-tech" => Some(GRADIENT_TECH_OP),
_ => None,
}
}
@ -55,14 +69,22 @@ pub enum TemplateScene {
Carousel,
/// Presentation decks.
Slides,
/// Single-board social cards — one finished image, not a sequence.
///
/// Distinct from [`Self::Carousel`]: a carousel's unit of meaning is the
/// swipe across several boards, so its templates ship a whole sequence. A
/// card is delivered on its own, which is what makes "title + points +
/// byline" a complete layout rather than a page of one.
Card,
}
impl TemplateScene {
pub const ALL: [Self; 4] = [
pub const ALL: [Self; 5] = [
Self::Tutorial,
Self::Comparison,
Self::Carousel,
Self::Slides,
Self::Card,
];
pub const fn as_str(self) -> &'static str {
@ -71,6 +93,7 @@ impl TemplateScene {
Self::Comparison => "comparison",
Self::Carousel => "carousel",
Self::Slides => "slides",
Self::Card => "card",
}
}
@ -81,16 +104,23 @@ impl TemplateScene {
Self::Comparison => "sceneTemplate.scene.comparison",
Self::Carousel => "sceneTemplate.scene.carousel",
Self::Slides => "sceneTemplate.scene.slides",
Self::Card => "sceneTemplate.scene.card",
}
}
/// Label shown when the catalogue key is not yet translated.
///
/// Chip-sized nouns, not descriptions: the filter row is a single
/// non-wrapping line. `Carousel` reads "轮播" rather than "知识卡片"
/// because the latter is what a [`Self::Card`] chip means — two chips
/// labelled the same thing is worse than a slightly drier word.
pub const fn title_fallback(self) -> &'static str {
match self {
Self::Tutorial => "教程图",
Self::Comparison => "对比图",
Self::Carousel => "知识卡片",
Self::Carousel => "轮播",
Self::Slides => "PPT",
Self::Card => "卡片",
}
}
}
@ -104,6 +134,7 @@ impl FromStr for TemplateScene {
"comparison" => Ok(Self::Comparison),
"carousel" => Ok(Self::Carousel),
"slides" => Ok(Self::Slides),
"card" => Ok(Self::Card),
_ => Err(()),
}
}

View file

@ -140,6 +140,22 @@ colour = "red"
);
}
/// A scene with no templates paints a chip that filters the grid to empty.
///
/// The scene enum and the catalogue are edited separately — adding a variant
/// is a compiler-guided change, adding its templates is not — so nothing else
/// stops a chip from shipping ahead of the documents it filters for.
#[test]
fn every_scene_has_at_least_one_template_so_no_filter_chip_is_dead() {
for scene in TemplateScene::ALL {
assert!(
scene_templates_for(scene).next().is_some(),
"scene `{}` has no templates — its filter chip would show an empty grid",
scene.as_str()
);
}
}
#[test]
fn every_scene_has_a_distinct_label_so_the_filter_row_is_unambiguous() {
let labels: Vec<&str> = TemplateScene::ALL

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -36,6 +36,12 @@ pub(crate) fn scene_template_preview(template_id: &str) -> Option<(u64, &'static
"knowledge-carousel" => preview!(2, "knowledge-carousel"),
"before-after" => preview!(3, "before-after"),
"slide-deck" => preview!(4, "slide-deck"),
"knowledge-card-vertical" => preview!(5, "knowledge-card-vertical"),
"knowledge-card-square" => preview!(6, "knowledge-card-square"),
"pitch-deck-dark" => preview!(7, "pitch-deck-dark"),
"lecture-deck-light" => preview!(8, "lecture-deck-light"),
"minimal-keynote" => preview!(9, "minimal-keynote"),
"gradient-tech" => preview!(10, "gradient-tech"),
_ => None,
}
}

View file

@ -8,7 +8,10 @@ OUT=$R/templates/step0/previews
TMP=$(mktemp -d)
mkdir -p "$OUT"
for t in screenshot-tutorial knowledge-carousel before-after slide-deck; do
for t in screenshot-tutorial knowledge-carousel before-after slide-deck \
knowledge-card-vertical knowledge-card-square \
pitch-deck-dark lecture-deck-light \
minimal-keynote gradient-tech; do
# The documents themselves are shipped assets (embedded by the scene
# template catalogue); this directory keeps only the generators and the
# full-resolution renders they produce.

View file

@ -46,6 +46,13 @@ CARDS = [
("knowledge-carousel", "knowledge-carousel-overview.png"),
("before-after", "before-after.png"),
("slide-deck", [f"slide-deck-{i:02d}.png" for i in range(1, 7)]),
("knowledge-card-vertical", "knowledge-card-vertical.png"),
("knowledge-card-square", "knowledge-card-square.png"),
("pitch-deck-dark", [f"pitch-deck-dark-{i:02d}.png" for i in range(1, 7)]),
("lecture-deck-light",
[f"lecture-deck-light-{i:02d}.png" for i in range(1, 7)]),
("minimal-keynote", [f"minimal-keynote-{i:02d}.png" for i in range(1, 9)]),
("gradient-tech", [f"gradient-tech-{i:02d}.png" for i in range(1, 7)]),
]
# Gap between tiles, in source pixels — scaled down with everything else.

View file

@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""knowledge-card-vertical.op + knowledge-card-square.op — 中文知识卡片
两套模板共用一份生成器因为它们是同一套设计语言的两个画幅竖版
1080×1440 发小红书图文方版 1080×1080 发公众号头图和朋友圈分成两个
文件写改一处配色就得记得改另一处合成一个两张卡永远同源
版式只做标题 + 要点 + 署名三段是刻意的卡片场景的用户要改的是
文案不是结构多一个装饰区就多一处他们得先看懂再删掉的东西
硬契约 skills 的中文排版约定一致
- 内容距边缘 80px竖版 88/96方版 84/88
- 配色全部走 color_vars改主色只改 $c-accent 一处
- 正文与背景对比度 2.0本配色最低一对是 3.67见文件末尾注释
- CJK 行高大标题 1.15小标题 1.3正文 1.6
- 顶层 frame 必须显式写 x/y否则多帧会全部堆在原点
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import Ids, color_vars, frame, rect, solid, text, write_doc
ids = Ids()
# 暖纸底 + 单一强调色。知识卡片是「读」的东西,底色带一点暖比纯白更耐看,
# 也和已有的 knowledge-carousel冷调靛蓝拉开距离。
VARS = color_vars({
"c-bg": "#FFF7F0",
"c-surface": "#FFFFFF",
"c-ink": "#241A12",
"c-muted": "#7A6455",
"c-accent": "#C8502B",
"c-accent-soft": "#FAE3D8",
"c-border": "#EDDCD0",
})
# 正文用的中文字族。oplib 的默认族按字号/字重挑(<34 且 <600 走 Inter
# 那条规则是为中英混排写的;这两张卡是纯中文,所以每个文本节点都显式写死
# 中文族,免得小字号正文落到一个没有汉字的字族上再靠 fallback 兜。
CJK = "Noto Sans SC"
# 序号和数字用 Inter等宽感更强"01" 这类标号排出来才齐。
NUM = "Inter"
def card(name, *, width, height, pad_y, pad_x, children):
"""一张卡。固定画幅,绝不 fit_content —— 发布尺寸是硬约束。
`space_between` 头部 / 要点 / 署名三段推到各自该在的位置
卡片的留白不该是均匀的 gap而是三段之间自然撑开的呼吸
"""
node = frame(ids, name, width=width, height=height, layout="vertical",
padding=[pad_y, pad_x], gap=0,
justifyContent="space_between", alignItems="start",
fill=solid("$c-bg"), clipContent=True)
node["children"] = children
node["x"] = 0
node["y"] = 0
return node
def block(name, children, gap=32):
"""透明结构容器。没有 fill 才是结构层;一旦有 fill 它就是一张卡面了。"""
node = frame(ids, name, width="fill_container", height="fit_content",
layout="vertical", gap=gap, alignItems="start", fill=[])
node["children"] = children
return node
def eyebrow(label, size=26):
"""小标签。整张卡唯一的胶囊,用来交代这是哪一类内容。"""
node = frame(ids, "标签", width="fit_content", height="fit_content",
layout="horizontal", padding=[12, 24], gap=0,
cornerRadius=999, alignItems="center",
justifyContent="center", fill=solid("$c-accent-soft"))
node["children"] = [
text(ids, "标签文字", label, size, 600, "$c-accent", family=CJK,
width="fit_content", growth="auto", line_height=1.4),
]
return node
def rule(width=132, height=12):
return rect(ids, "强调短线", width=width, height=height,
cornerRadius=height // 2, fill=solid("$c-accent"))
def numbered(no, size):
"""实心序号圆。白字压在主色上,是卡里对比度最高的一处,读者的视线锚点。"""
node = frame(ids, f"序号 {no}", width=size, height=size,
layout="horizontal", alignItems="center",
justifyContent="center", cornerRadius=size // 2,
fill=solid("$c-accent"))
node["children"] = [
text(ids, "序号数字", str(no), round(size * 0.5), 700, "#FFFFFF",
family=NUM, width="fit_content", growth="auto", line_height=1.0),
]
return node
def point(no, title, desc, *, badge, title_size, desc_size, gap):
node = frame(ids, f"要点 {no}", width="fill_container",
height="fit_content", layout="horizontal", gap=gap,
alignItems="start", fill=[])
node["children"] = [
numbered(no, badge),
block("要点文案", [
text(ids, "要点标题", title, title_size, 600, "$c-ink",
family=CJK, line_height=1.3),
text(ids, "要点说明", desc, desc_size, 400, "$c-muted",
family=CJK, line_height=1.6),
], gap=10),
]
return node
def signature(*, avatar, name_size, slogan_size, gap):
"""署名条。一条细分隔线 + 头像占位 + 名字 + 一句 slogan。
头像用 frame 而不是 rectangleframe 会递归渲染子节点圆里那个字才
显示得出来同时 frame 也是合法的图片拖放目标用户把自己的头像拖上去
就直接替换掉这个占位
"""
disc = frame(ids, "头像占位", width=avatar, height=avatar,
layout="horizontal", alignItems="center",
justifyContent="center", cornerRadius=avatar // 2,
fill=solid("$c-accent-soft"))
disc["children"] = [
text(ids, "头像文字", "", round(avatar * 0.42), 700, "$c-accent",
family=CJK, width="fit_content", growth="auto", line_height=1.0),
]
row = frame(ids, "署名行", width="fill_container", height="fit_content",
layout="horizontal", gap=gap, alignItems="center", fill=[])
row["children"] = [
disc,
block("署名文案", [
text(ids, "账号名", "@ 你的账号名", name_size, 600, "$c-ink",
family=CJK, line_height=1.3),
text(ids, "一句话简介", "每周更新一条可落地的学习方法",
slogan_size, 400, "$c-muted", family=CJK, line_height=1.5),
], gap=6),
]
divider = rect(ids, "分隔线", width="fill_container", height=2,
fill=solid("$c-border"))
return block("署名", [divider, row], gap=gap)
# ----------------------------------------------------------- 竖版 1080×1440
VERTICAL_POINTS = [
("先提问,再翻开",
"带着「我想解决什么」进去,信息才有地方落。"),
("合上书,先复述",
"读完立刻用自己的话讲一遍,卡壳的地方就是没懂的地方。"),
("一次只带走三条",
"贪多必忘。记住三个能用的点,比划满一整本有用。"),
("两天之内用一次",
"写进笔记、讲给朋友、或者直接做一遍,用过才算学过。"),
]
def vertical():
head = block("卡头", [
eyebrow("学习方法 · 01"),
text(ids, "主标题", "为什么你读了很多书\n却什么都记不住", 88, 700,
"$c-ink", family=CJK, line_height=1.15),
rule(),
text(ids, "副标题", "不是记性差,是你少做了这一步。", 30, 400,
"$c-muted", family=CJK, line_height=1.5),
], gap=32)
points = block("要点列表", [
point(no, title, desc, badge=56, title_size=36, desc_size=27, gap=24)
for no, (title, desc) in enumerate(VERTICAL_POINTS, 1)
], gap=28)
return card("知识卡片 · 竖版", width=1080, height=1440, pad_y=96, pad_x=88,
children=[head, points,
signature(avatar=88, name_size=30, slogan_size=24,
gap=24)])
# ----------------------------------------------------------- 方版 1080×1080
SQUARE_POINTS = [
("先提问,再翻开",
"带着问题进去,信息才有地方落。"),
("合上书,先复述",
"讲不出来的地方,就是还没懂的地方。"),
("两天之内用一次",
"用过一次,才算真的学过。"),
]
def square():
head = block("卡头", [
eyebrow("学习方法"),
text(ids, "主标题", "读完就忘?\n先补上这一步", 76, 700, "$c-ink",
family=CJK, line_height=1.15),
rule(112, 10),
text(ids, "副标题", "三个动作,把书里的东西真正带走。", 28, 400,
"$c-muted", family=CJK, line_height=1.5),
], gap=26)
points = block("要点列表", [
point(no, title, desc, badge=52, title_size=34, desc_size=26, gap=22)
for no, (title, desc) in enumerate(SQUARE_POINTS, 1)
], gap=24)
return card("知识卡片 · 方版", width=1080, height=1080, pad_y=88, pad_x=84,
children=[head, points,
signature(avatar=76, name_size=28, slogan_size=23,
gap=22)])
# 对比度WCAG 相对亮度比op-design-lint 的门槛是 2.0
# c-ink on c-bg 16.09 c-muted on c-bg 5.24
# c-accent on c-bg 4.27 c-accent on c-accent-soft 3.67
# #FFFFFF on c-accent 4.53
# 最低一对 3.67 仍高出门槛近一倍,换主色时保留这个余量即可。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, [vertical()], "知识卡片 · 竖版 3:4")
write_doc(sys.argv[2], VARS, [square()], "知识卡片 · 方版 1:1")

View file

@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""gradient-tech.op — 渐变科技风 deck6 页 1920×1080
深底 + 大面积渐变 + 玻璃拟态卡开发者产品口吻这套同时是引擎能力的展台
线性渐变径向光晕玻璃卡三样都是 schema 原生支持渲染栈真画得出来的
渲染探针见交付说明
## 玻璃卡为什么不用真 alpha
玻璃拟态的自然写法是半透明白 `#FFFFFF1F`。渲染没问题,但 op-design-lint
的对比度检测器 `parse_hex_color` 会丢掉 alpha 通道把这张卡当成**纯白不
透明**卡里的白字于是被判成 1.00:1而真实合成结果是深紫上的白字后果
不只是误报它会让这张卡内的所有文字都失去有效检测真出现低对比配色也
照样不报
所以这里的玻璃卡用不透明渐变stops @ GLASS_ALPHA 合成到页面
渐变对应 stop的结果光学上与真 alpha 一致对照渲染验证过但不含
alpha 通道代价是 stops 是烤死的改了下面的 GRADIENT 就必须重跑本生成
不能只改 .op 里的变量
排版遵循 skills/domains/slides.md 的硬契约
- 每帧固定 1920×1080绝不 fit_content内容距边 100这里 120
- 正文 24 26-30标题 40关键数字 80-200数据页取 132
- 行高标题 1.1-1.2正文 1.45+
- 最多 2 个字体族Noto Sans SC 排中文Inter 排数字与代码
- 强调色只用于强调
渐变底上的对比度按**渐变两端分别实测**比单点检测更严最低一对
4.85: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, text, write_doc
ids = Ids()
# 页面渐变的三个 stop深紫 → 靛 → 蓝。玻璃卡的合成色由它们算出来,所以
# 这里是整套配色的唯一真源。
GRADIENT = [(0.0, "#1B0B3B"), (0.55, "#2A1E6E"), (1.0, "#0A2C6E")]
# 玻璃的白覆盖率。这个数是承重的0.12 时强调色压在最亮那段玻璃上是
# 4.85:1提到 0.18 就掉到 4.00 —— 想让玻璃更亮,得先把强调色一起调。
GLASS_ALPHA = 0.12
GLASS_EDGE_ALPHA = 0.30
def over(alpha, background):
"""白色以 alpha 覆盖 background 后的不透明结果(直接的 source-over"""
channels = [int(background[i:i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(
f"{round(alpha * 255 + (1 - alpha) * c):02X}" for c in channels
)
VARS = color_vars({
"c-grad-a": GRADIENT[0][1],
"c-grad-b": GRADIENT[1][1],
"c-grad-c": GRADIENT[2][1],
"c-ink": "#FFFFFF",
"c-muted": "#C9D4F5",
"c-accent": "#3AC8FF",
# 对比条里「被比下去」的那根。用正文的 c-muted 会让基线条比强调条还
# 抢眼(它更长),所以单独给一档更收的合成色;条不是文字,不参与对比
# 度门槛。
"c-bar-base": over(0.45, GRADIENT[1][1]),
# 玻璃卡的三段合成色(由 GRADIENT + GLASS_ALPHA 算出,改了要重跑)。
"c-glass-a": over(GLASS_ALPHA, GRADIENT[0][1]),
"c-glass-b": over(GLASS_ALPHA, GRADIENT[1][1]),
"c-glass-c": over(GLASS_ALPHA, GRADIENT[2][1]),
# 描边不参与对比度检测,这里可以放心用真 alpha 取那道细白边。
"c-glass-edge": "#FFFFFF4D",
})
W, H = 1920, 1080
EDGE = 120
CJK = "Noto Sans SC"
NUM = "Inter"
# 角度 135 = 左上 → 右下(渲染实测)。
PAGE_ANGLE = 135
def page_fill():
return [{
"type": "linear_gradient",
"angle": PAGE_ANGLE,
"stops": [{"offset": offset, "color": f"$c-grad-{key}"}
for (offset, _), key in zip(GRADIENT, "abc")],
}]
def glass_fill():
"""玻璃卡体。见文件头:不透明渐变,不是 alpha。"""
return [{
"type": "linear_gradient",
"angle": PAGE_ANGLE,
"stops": [{"offset": offset, "color": f"$c-glass-{key}"}
for (offset, _), key in zip(GRADIENT, "abc")],
}]
def glass_edge():
return {"thickness": 2, "fill": solid("$c-glass-edge")}
def slide(name, *, gap=56, justify="start"):
"""一帧。固定 1920×1080底是整页渐变。"""
node = frame(ids, name, width=W, height=H, layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=page_fill(), clipContent=True)
node["children"] = []
return node
def col(name, children, *, gap=16, width="fill_container",
height="fit_content", align="start", fill=None, **props):
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=32, 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
def glass_card(name, children, *, gap=18, height="fit_content",
padding=(40, 32), **props):
return col(name, children, gap=gap, height=height,
padding=[padding[0], padding[1]], cornerRadius=20,
fill=glass_fill(), stroke=glass_edge(), **props)
def centered_body(name, child):
"""内容块在页标题以下的整块空间里垂直居中,页标题每页同高。"""
return col(name, [child], gap=0, height="fill_container",
justifyContent="center")
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=glass_fill(),
stroke=glass_edge())
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 page_head(title, subtitle=None, *, size=60):
kids = [text(ids, "页标题", title, size, 700, "$c-ink", family=CJK,
line_height=1.15)]
if subtitle:
kids.append(text(ids, "页副标题", subtitle, 30, 400, "$c-muted",
family=CJK, line_height=1.45))
return col("页头", kids, gap=18)
def mono(name, content, size=26, color="$c-accent"):
"""代码腔的一行。Inter 不是等宽,但整套只允许两个字族,用字重区分。"""
return text(ids, name, content, size, 500, color, family=NUM,
width="fit_content", growth="auto", line_height=1.5)
# ------------------------------------------------------------------ 01 封面
def cover():
lede = col("主张", [
text(ids, "主标题", "让每一次调用\n都在 20 毫秒内返回", 96, 700,
"$c-ink", family=CJK, line_height=1.12),
text(ids, "副标题", "边缘推理网关 v3.0,现已开放公测。", 32, 400,
"$c-muted", family=CJK, line_height=1.45),
], gap=32)
install = glass_card("安装行", [
mono("安装命令", "$ npm i -g edgegate && edgegate up", 28),
], gap=0, padding=(26, 32), width="fit_content")
s = slide("01 封面", gap=48, justify="space_between")
s["children"] = [eyebrow("v3.0 · 公测"), lede, install]
return s
# ------------------------------------------------------------------ 02 痛点
PAINS = [
("冷启动吃掉了首包",
"函数实例每次唤醒要 800ms用户看到的却是整整一秒的白屏。"),
("账单没法归因",
"月底一张总额,分不清哪个模型、哪条链路花掉的。"),
("换云就得重写",
"每家的运行时都不一样,迁移一次等于把接入层重做一遍。"),
]
def problems():
cards = [
glass_card("痛点卡", [
text(ids, "痛点序号", f"0{index}", 30, 700, "$c-accent",
family=NUM, width="fit_content", growth="auto",
line_height=1.1),
text(ids, "痛点标题", title, 36, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "痛点说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=18, height="fill_container", padding=(44, 36))
for index, (title, desc) in enumerate(PAINS, 1)
]
s = slide("02 痛点")
s["children"] = [
page_head("在边缘跑推理,卡在三件事上",
"都不是模型的问题,是它前面那一层的问题。"),
centered_body("痛点区", row("痛点网格", cards, gap=32,
align="stretch")),
]
return s
# ------------------------------------------------------------------ 03 架构
LAYERS = [
("接入层", "Gateway", "一个 Anycast 入口,就近落到最快的可用区。"),
("调度层", "Scheduler", "按延迟预算和单价实时选模型,超时自动降级。"),
("运行层", "Runtime", "常驻实例池,冷启动摊薄到 3 毫秒以内。"),
]
def architecture():
cards = []
for index, (name, en, desc) in enumerate(LAYERS, 1):
head = row("层头", [
text(ids, "层名", name, 36, 600, "$c-ink", family=CJK,
width="fit_content", growth="auto", line_height=1.25),
mono("层英文名", en, 26, "$c-accent"),
], gap=16, align="center", width="fit_content")
cards.append(glass_card("架构层", [
head,
text(ids, "层说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=12, padding=(32, 36)))
s = slide("03 架构")
s["children"] = [
page_head("三层,一层只做一件事",
"自上而下,每一层都能单独替换。"),
centered_body("架构区", col("架构堆叠", cards, gap=20)),
]
return s
# ------------------------------------------------------------------ 04 性能
METRICS = [
("20", "ms", "P50 端到端", "含调度与网络"),
("3", "ms", "冷启动", "常驻池摊薄后"),
("62", "%", "成本下降", "同等 QPS 对比自建"),
]
# 对比条:条长按真实数值成比例(越短越快),数字写在标签里。
BAR_TRACK = 1600
BAR_OURS_MS = 20
BAR_BASE_MS = 210
def metric_card(value, unit, label, note):
head = row("数值", [
text(ids, "数值文字", value, 132, 700, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0),
# 单位跟着数字基线走lineHeight 1.0 下 `end` 会把小字压到基线以
# 下这层只有下内距的盒子把它抬回来jian 没有真 baseline 对齐)。
col("单位", [
text(ids, "单位文字", unit, 40, 600, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0),
], gap=0, width="fit_content", padding=[0, 0, round((132 - 40) * 0.2), 0]),
], gap=8, align="end", width="fit_content")
return glass_card("性能卡", [
head,
text(ids, "指标名", label, 30, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "指标说明", note, 26, 400, "$c-muted", family=CJK,
line_height=1.45),
], gap=16, height="fill_container", padding=(40, 32))
def bar(label, milliseconds, color):
width = round(BAR_TRACK * milliseconds / BAR_BASE_MS)
return col("对比条组", [
text(ids, "条目标签", label, 26, 500, "$c-muted", family=CJK,
line_height=1.4),
rect(ids, "对比条", width=width, height=16, cornerRadius=999,
fill=solid(color)),
], gap=10)
def performance():
bars = glass_card("延迟对比", [
text(ids, "对比标题", "端到端延迟对比", 30, 600, "$c-ink",
family=CJK, line_height=1.25),
bar("本产品 · 20ms", BAR_OURS_MS, "$c-accent"),
bar("自建方案 · 210ms", BAR_BASE_MS, "$c-bar-base"),
], gap=20, padding=(32, 40))
# 三张卡与对比条合成一块「性能区」:直接摊在帧下会让页头、网格、对比
# 卡成为三个平级兄弟其中只有对比卡有圆角mixed-sibling-corner-radius
# 会照实报出来(也确实读着像手滑)。
body = col("性能区", [
row("性能网格", [metric_card(*entry) for entry in METRICS],
gap=32, align="stretch"),
bars,
], gap=32)
s = slide("04 性能", gap=44)
s["children"] = [
page_head("数字是压测出来的,不是估的"),
centered_body("性能居中", body),
]
return s
# ------------------------------------------------------------------ 05 客户
LOGO_SLOTS = 8
def customers():
# 每个格子是 frame 而不是 rectangleframe 会递归渲染子节点(里面那行
# 提示才显示得出来),同时它是合法的图片拖放目标,把客户 logo 拖上去
# 就直接替换掉占位。
slots = []
for index in range(LOGO_SLOTS):
slot = frame(ids, f"Logo 位 {index + 1}", width="fill_container",
height="fill_container", layout="vertical", gap=0,
alignItems="center", justifyContent="center",
cornerRadius=16, fill=glass_fill(), stroke=glass_edge())
slot["children"] = [
text(ids, "占位提示", "拖入 Logo", 24, 500, "$c-muted",
family=CJK, width="fit_content", growth="auto",
line_height=1.4),
]
slots.append(slot)
grid = col("Logo 网格", [
row("Logo 行", slots[0:4], gap=28, align="stretch",
height="fill_container"),
row("Logo 行", slots[4:8], gap=28, align="stretch",
height="fill_container"),
], gap=28, height=420)
s = slide("05 客户")
s["children"] = [
page_head("已经在生产环境上跑",
"八家团队,日均 27 亿次调用。"),
centered_body("Logo 区", grid),
]
return s
# ------------------------------------------------------------------ 06 CTA
def call_to_action():
lede = col("结语", [
text(ids, "结语标题", "十分钟接完\n剩下的交给我们", 84, 700,
"$c-ink", family=CJK, line_height=1.15),
text(ids, "结语副标题", "公测期不限调用量,接入文档一页写完。",
30, 400, "$c-muted", family=CJK, line_height=1.45),
], gap=30)
actions = row("行动区", [
glass_card("安装卡", [
mono("安装命令", "$ npm i -g edgegate", 28),
], gap=0, padding=(26, 32), width="fit_content"),
glass_card("文档卡", [
mono("文档地址", "docs.edgegate.dev", 28, "$c-ink"),
], gap=0, padding=(26, 32), width="fit_content"),
], gap=24, align="center", width="fit_content")
s = slide("06 CTA", gap=48, justify="space_between")
s["children"] = [eyebrow("公测开放中"), lede, actions]
return s
# 顶层 frame 必须显式写 x/y缺省时每帧都落在原点六页会叠成一页。纵向
# gap 比横向多留 240是给画布上固定在帧顶部的帧名标签让位。
BOARD_GAP_X = 120
BOARD_GAP_Y = BOARD_GAP_X + 240
BOARDS_PER_ROW = 3
def build():
boards = [cover(), problems(), architecture(), performance(),
customers(), call_to_action()]
for index, board in enumerate(boards):
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
# 压在页面渐变上(三个 stop
# c-ink 18.14 / 13.91 / 13.14 c-muted 12.28 / 9.42 / 8.90
# c-accent 9.40 / 7.21 / 6.81
# 压在玻璃卡上(白 @0.12 合成后的三段):
# c-ink 13.25 / 9.82 / 9.36 c-muted 8.97 / 6.65 / 6.34
# c-accent 6.87 / 5.09 / 4.85 ← 最低一对
# 注意 GLASS_ALPHA 是承重参数:提到 0.18c-accent 会掉到 4.00。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "渐变科技风 · 16:9")

View file

@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""lecture-deck-light.op — 浅色课件 deck封面 / 目标 / 讲解 / 例题 / 对比 / 小结)
课件跟汇报 deck 不是同一件事听众要跟着做笔记所以正文比路演更密
完整序号和步骤必须一眼数得清同时它会被投在教室最后一排也看得见的地
所以字号一点不能省米白纸底 + 墨绿是为了长时间盯着看不刺眼
排版遵循 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, 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",
})
W, H = 1920, 1080
EDGE = 120
CJK = "Noto Sans SC"
NUM = "Inter"
def slide(name, *, gap=56, justify="start", fill="$c-bg"):
"""一帧课件。固定 1920×1080 —— 投影比例是硬约束,不是建议。"""
node = frame(ids, name, width=W, height=H, layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=solid(fill), clipContent=True)
node["children"] = []
return node
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
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 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 page_head(title, subtitle=None, *, size=64):
kids = [text(ids, "页标题", title, size, 700, "$c-ink", family=CJK,
line_height=1.15)]
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与极值判定", 100, 700, "$c-ink",
family=CJK, line_height=1.12),
accent_bar(),
text(ids, "课程说明", "这一讲我们只解决一个问题:拿到一个函数,怎么"
"判断它在哪段往上走。", 32, 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["children"] = [eyebrow("第 07 讲"), lede, meta]
return s
# ------------------------------------------------------------------ 02 目标
OBJECTIVES = [
("说得出定义", "用导数的符号说明单调递增、递减到底在讲什么。"),
("判得出区间", "给定一个可导函数,能划出它的单调区间。"),
("找得到极值", "会用一阶导数的符号变化确定极大值与极小值。"),
]
def objectives():
items = []
for index, (title, desc) in enumerate(OBJECTIVES, 1):
items.append(row(f"目标 {index}", [
numbered(index, 64),
col("目标文案", [
text(ids, "目标标题", title, 38, 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", padding=[36, 40], cornerRadius=20,
fill=solid("$c-surface")))
s = slide("02 学习目标", gap=56)
s["children"] = [
page_head("这节课下课时,你应该能——",
"三条,都要能自己动手做出来,不是听懂就算。"),
centered_body("目标区", col("目标列表", items, gap=24)),
]
return s
# ------------------------------------------------------------------ 03 讲解
def concept():
body = col("概念正文", [
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", justifyContent="space_between",
padding=[44, 44], cornerRadius=20, fill=solid("$c-surface"))
# 配图位用 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)
s["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, 52),
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 = col("例题卡", [
prompt,
col("解答步骤", steps, gap=24),
], gap=32, padding=[40, 44], cornerRadius=20, fill=solid("$c-surface"))
s = slide("04 例题", gap=56)
s["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)
s["children"] = [
page_head("两个概念,别再混着用",
"单调性说的是一段,极值说的是一点。"),
centered_body("对比区", table),
]
return s
# ------------------------------------------------------------------ 06 小结
TAKEAWAYS = [
"先求导,再找驻点,最后判符号——顺序不能换。",
"驻点只是候选,符号真的变了才是极值点。",
"不可导的点也要单独拿出来讨论。",
]
HOMEWORK = [
("课本 P128", "习题 3.4 第 1、3、5 题"),
("补充题", "讨论 f(x) = x·e^(x) 的单调区间与极值"),
("下次课前", "预习凹凸性与拐点P131P136"),
]
def summary():
left = col("本讲小结", [
text(ids, "小结标题", "本讲小结", 40, 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], cornerRadius=20,
fill=solid("$c-surface"))
right = col("课后作业", [
text(ids, "作业标题", "课后作业", 40, 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)
s["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["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 的下限总是出在
# 「弱化文字压在弱化底色上」这一对,不是出在标题上。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "课件 deck · 浅色 16:9")

View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""minimal-keynote.op — 极简 Keynote deck8 页 1920×1080
这套的难点是没有卡片没有描边没有底色块一页只留 2-4 个元素
版面全靠字阶落差和留白撑元素越少任何一处对不齐都无处遁形所以整套
只有一条左基线EDGE=160每页的内容块都从这条线起排纵向居中
跟已有三套的区别slide-deck 是暖白商务pitch-deck-dark 是深底路演
lecture-deck-light 是纸白课件它们都靠卡片组织信息这一套刻意不给
任何容器上色是发布会舞台上那种一句话一屏的节奏
排版遵循 skills/domains/slides.md 的硬契约
- 每帧固定 1920×1080绝不 fit_content内容距边 100这里 160
- 正文 24 28-32标题 40关键数字 80-200数据页取 200
- 行高展示字 1.05-1.15正文 1.45+
- 最多 2 个字体族Noto Sans SC 排中文Inter 排数字靠字重分层
- 强调色只用于强调
白底上的对比度WCAG实测见文件末尾最低一对是 5.33: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, text, write_doc
ids = Ids()
# 纯白 + 近黑 + 一个克制的朱红。
#
# 强调色没有用常见的 #FF5A36那个橙在纯白上只有 3.10:1当文字用不到
# 4.5 的可读线(实测,见文件末尾)。降明度到 #C7340F 后是 5.35:1色相
# 仍在同一族,投影时反而更沉得住。整套只有这一个彩色,出现三次以内。
VARS = color_vars({
"c-bg": "#FFFFFF",
"c-ink": "#0A0A0A",
"c-muted": "#6B6B6B",
"c-accent": "#C7340F",
})
W, H = 1920, 1080
# 留白是这套模板唯一的「装饰」,所以边距给到 160 —— 契约下限 100 的 1.6 倍。
EDGE = 160
CJK = "Noto Sans SC"
NUM = "Inter"
def slide(name, *, gap=40, justify="center"):
"""一帧。固定 1920×1080无底色块、无描边 —— 白就是版面本身。"""
node = frame(ids, name, width=W, height=H, layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=solid("$c-bg"), clipContent=True)
node["children"] = []
return node
def col(name, children, *, gap=16, width="fill_container",
height="fit_content", **props):
"""透明结构容器。这套模板里没有任何一个容器该有 fill。"""
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, alignItems="start", fill=[], **props)
node["children"] = children
return node
def row(name, children, *, gap=32, align="center", **props):
node = frame(ids, name, width="fill_container", height="fit_content",
layout="horizontal", gap=gap, alignItems=align, fill=[],
**props)
node["children"] = children
return node
def accent_rule(width=120, height=8):
"""整套唯一的图形元素。一条短线,替代所有本该是卡片边框的东西。"""
return rect(ids, "强调短线", width=width, height=height,
fill=solid("$c-accent"))
# ------------------------------------------------------------------ 01 封面
def cover():
lede = col("主张", [
text(ids, "主标题", "把复杂\n留在里面", 130, 700, "$c-ink",
family=CJK, line_height=1.08),
accent_rule(),
text(ids, "副标题", "第七代产品,今天发布。", 32, 400, "$c-muted",
family=CJK, line_height=1.45),
], gap=40)
meta = row("落款", [
text(ids, "场合", "2026 秋季发布会", 26, 500, "$c-ink", family=CJK,
width="fit_content", growth="auto"),
text(ids, "日期", "2026.10.08", 26, 400, "$c-muted", family=NUM,
width="fit_content", growth="auto"),
], gap=28, justifyContent="space_between")
s = slide("01 封面", gap=48, justify="space_between")
s["children"] = [lede, meta]
return s
# ------------------------------------------------------------ 02 一句话论点
def statement():
s = slide("02 论点", gap=44)
s["children"] = [
rect(ids, "强调点", width=18, height=18, cornerRadius=9,
fill=solid("$c-accent")),
text(ids, "论点", "我们删掉了三十七个按钮。", 76, 700, "$c-ink",
family=CJK, line_height=1.15),
]
return s
# ------------------------------------------------------------- 03-05 词页
# 一页一个字。注解只有一行,写不下就说明这个词选错了。
WORDS = [
("", "从三十七个按钮,到三个。"),
("", "整机一点零九公斤,比上一代少了两百四十克。"),
("", "一次充电,用满两天。"),
]
def word_page(index, word, note):
s = slide(f"0{index + 3} 词 · {word}", gap=36)
s["children"] = [
text(ids, "大词", word, 200, 700, "$c-ink", family=CJK,
line_height=1.05),
text(ids, "注解", note, 30, 400, "$c-muted", family=CJK,
line_height=1.5),
]
return s
# ------------------------------------------------------------------ 06 引用
def quote():
s = slide("06 引用", gap=32)
s["children"] = [
# 引号本身当图形用替掉一切边框和背景块。lineHeight 压到 0.5 是
# 量出来的:引号的墨迹只占 em 盒顶部约三分之一,默认行高会在它下面
# 留出一大片空行盒,看起来就像 gap 没设对(同 oplib 里 `●` 用
# DOT_LINE_HEIGHT 收行盒的做法)。
text(ids, "引号", "", 160, 700, "$c-accent", family=NUM,
line_height=0.5),
text(ids, "引文", "最好的设计,是让人忘了它存在。", 54, 600,
"$c-ink", family=CJK, line_height=1.3),
text(ids, "出处", "— 产品负责人 沈亦文", 28, 400, "$c-muted",
family=CJK, line_height=1.5),
]
return s
# ------------------------------------------------------------- 07 数据一句话
def one_number():
s = slide("07 数据", gap=32)
s["children"] = [
# 契约允许关键数字 80-200这里取上限整页只有它和一行注。
text(ids, "大数字", "0.4 秒", 200, 700, "$c-ink", family=NUM,
line_height=1.05),
text(ids, "注解", "从按下到出图的全部时间,上一代是 2.6 秒。",
32, 400, "$c-muted", family=CJK, line_height=1.5),
]
return s
# ------------------------------------------------------------------ 08 结尾
def closing():
s = slide("08 结尾", gap=40)
s["children"] = [
accent_rule(),
text(ids, "结语", "今天开始发货。", 96, 700, "$c-ink", family=CJK,
line_height=1.12),
text(ids, "补充", "全国门店同步开售,官网可预约体验。", 30, 400,
"$c-muted", family=CJK, line_height=1.5),
]
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(), statement()]
boards += [word_page(i, w, n) for i, (w, n) in enumerate(WORDS)]
boards += [quote(), one_number(), closing()]
for index, board in enumerate(boards):
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 19.80
# c-muted on c-bg 5.33 ← 最低一对
# c-accent on c-bg 5.35
# 换强调色时先量它在纯白上的比值:橙红一族很容易掉到 4.5 以下
# #FF5A36 = 3.10、#E5471F = 4.00 都不够,#C7340F = 5.35 才过)。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "极简 Keynote · 16:9")

View file

@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""pitch-deck-dark.op — 深色路演 deck封面 / 问题 / 方案 / 数据 / 里程碑 / CTA
已有的 slide-deck 是暖白商务风适合汇报这一套是它的反面黑底单强调
大字号少而重的信息路演是在别人只肯给你八分钟的房间里把一件
事讲到对方想追问所以每页只有一个视觉锚点其余全部让位
排版遵循 skills/domains/slides.md 的硬契约
- 每帧固定 1920×1080绝不 fit_content内容距边缘 100这里 120
- 正文 24 26-30标题 40关键数字 80-200数据页取 140
- 行高标题 1.1-1.2正文 1.4+
- 最多 2 个字体族Noto Sans SC 排中文Inter 排数字靠字重分层
- 强调色只用于强调正文一律中性色
深底上的对比度WCAG实测见文件末尾最低一对是 4.73: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, stroke, text, write_doc
ids = Ids()
# 近黑的石墨蓝 + 一个电光蓝。深色 deck 最容易翻车的地方是「深色 = 一堆
# 灰」所以底色只有两级bg / surface层级靠字重和强调色拉不靠再多
# 铺几层灰面。
VARS = color_vars({
"c-bg": "#0B1220",
"c-surface": "#18263F",
"c-ink": "#F2F6FF",
"c-muted": "#93A4C4",
"c-accent": "#4D8DFF",
"c-accent-soft": "#16233D",
"c-border": "#24314D",
})
W, H = 1920, 1080
EDGE = 120
# 中文字族写死oplib 的默认族按字号/字重挑(<34 且 <600 走 Inter那条
# 规则是给中英混排写的;这套 deck 是纯中文正文,小字号也必须落在有汉字的
# 字族上,否则要靠 fallback 兜。
CJK = "Noto Sans SC"
# 数字、单位符号走 Inter等宽感更强KPI 排出来才齐。
NUM = "Inter"
def slide(name, *, gap=56, justify="start", fill="$c-bg"):
"""一帧幻灯片。固定 1920×1080 —— 投影比例是硬约束,不是建议。"""
node = frame(ids, name, width=W, height=H, layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=solid(fill), clipContent=True)
node["children"] = []
return node
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
def eyebrow(label):
"""小标签。整套 deck 唯一的胶囊,用来交代「这是什么场合」。"""
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=160, height=10):
return rect(ids, "强调条", width=width, height=height, cornerRadius=999,
fill=solid("$c-accent"))
def divider():
return rect(ids, "分隔线", width="fill_container", height=2,
fill=solid("$c-border"))
def centered_body(name, child):
"""把内容块撑到页标题以下的整块空间里再垂直居中。
页标题每页固定在同一高度重复 deck 读起来整齐的前提正文块又不
该被拉长到失真两个诉求靠这一层透明容器同时满足
"""
return col(name, [child], gap=0, height="fill_container",
justifyContent="center")
def page_head(title, subtitle=None, *, size=64):
kids = [text(ids, "页标题", title, size, 700, "$c-ink", family=CJK,
line_height=1.15)]
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压进三分钟", 112, 700, "$c-ink",
family=CJK, line_height=1.12),
accent_bar(),
text(ids, "副标题", "我们在做的,是让每一家工厂当场就能报出价来。",
34, 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.08 · 种子轮路演", 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["children"] = [eyebrow("种子轮 · 融资路演"), lede, meta]
return s
# ------------------------------------------------------------------ 02 问题
PAINS = [
("算不清", "一份报价要问遍四个部门,等两天才知道能不能接。"),
("报不准", "凭经验拍的价,做完才发现毛利被工时和损耗吃光了。"),
("跟不上", "客户当场要数,我们只能说「回去核一下」——单子就这么走了。"),
]
def problem():
rows = []
for index, (title, desc) in enumerate(PAINS, 1):
if rows:
rows.append(divider())
rows.append(row(f"痛点 {index}", [
text(ids, "痛点序号", f"0{index}", 64, 700, "$c-accent",
family=NUM, width="fit_content", growth="auto",
line_height=1.0),
col("痛点文案", [
text(ids, "痛点标题", title, 40, 600, "$c-ink", family=CJK,
line_height=1.2),
text(ids, "痛点说明", desc, 28, 400, "$c-muted", family=CJK,
line_height=1.5),
], gap=12),
], gap=40, align="start"))
s = slide("02 问题", gap=56)
s["children"] = [
page_head("报价这一步,卡住了整条链路",
"三个问题,每一个都在直接吃掉订单。"),
col("痛点列表", rows, gap=0, height="fill_container",
justifyContent="space_between"),
]
return s
# ------------------------------------------------------------------ 03 方案
SELLING_POINTS = [
("三分钟出价", "上传图纸,系统直接算出工时、材料与损耗。"),
("毛利先看见", "每一档价格旁边就是毛利率,接不接一眼决定。"),
("越报越准", "每完成一单回流真实成本,模型自己往回修。"),
]
def solution():
cards = []
for title, desc in SELLING_POINTS:
cards.append(col("卖点卡", [
rect(ids, "卡首强调条", width=56, height=6, cornerRadius=999,
fill=solid("$c-accent")),
text(ids, "卖点标题", title, 38, 600, "$c-ink", family=CJK,
line_height=1.2),
text(ids, "卖点说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=22, height="fill_container", padding=[48, 40],
cornerRadius=20, fill=solid("$c-surface")))
s = slide("03 方案", gap=56)
s["children"] = [
page_head("把报价变成一次点击",
"工艺库 + 实时成本模型,客户还在电话里,价就出来了。"),
centered_body("卖点区", row("卖点网格", cards, gap=36,
align="stretch")),
]
return s
# ------------------------------------------------------------------ 04 数据
METRICS = [
("42", "", "报价提速", "两天的活儿,三分钟出结果"),
("97", "%", "报价准确率", "与实际成本偏差不超过 3%"),
("2.3", "亿元", "在跑的订单金额", "来自 46 家制造企业"),
]
# 大数字 lineHeight 1.0 时基线离行盒底部约 0.2em;单位字号小得多,`end`
# 对齐会把它压到基线以下。这个补偿把单位的行盒底抬回大数字的基线附近,
# 让「42」和「×」看起来站在同一条线上。jian 没有真正的 baseline 对齐
# AlignItems::from_css 把 "baseline" 折成 End所以只能这样补。
VALUE_SIZE = 140
UNIT_SIZE = 44
UNIT_BASELINE_LIFT = round((VALUE_SIZE - UNIT_SIZE) * 0.2)
def unit_family(unit):
"""汉字单位(亿元)排中文族,符号单位(× %)排 Inter 跟数字同源。"""
return CJK if any(ord(ch) > 0x2FFF for ch in unit) else NUM
def metric_card(value, unit, label, note):
unit_box = frame(ids, "单位", width="fit_content", height="fit_content",
layout="vertical", gap=0,
padding=[0, 0, UNIT_BASELINE_LIFT, 0], fill=[])
unit_box["children"] = [
text(ids, "单位文字", unit, UNIT_SIZE, 600, "$c-accent",
family=unit_family(unit), width="fit_content", growth="auto",
line_height=1.0),
]
return col("数据卡", [
row("数值", [
text(ids, "数值文字", value, VALUE_SIZE, 700, "$c-accent",
family=NUM, width="fit_content", growth="auto",
line_height=1.0),
unit_box,
], gap=10, align="end", width="fit_content"),
text(ids, "指标名", label, 32, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "指标说明", note, 26, 400, "$c-muted", family=CJK,
line_height=1.45),
], gap=20, height="fill_container", justifyContent="center",
padding=[56, 44], cornerRadius=24, fill=solid("$c-surface"))
def metrics():
s = slide("04 数据", gap=56)
s["children"] = [
page_head("半年里,它已经在跑",
"不是路演里的假设,是 46 家工厂每天在用的数。"),
centered_body("数据区", row("数据网格",
[metric_card(*entry) for entry in METRICS],
gap=36, align="stretch")),
]
return s
# --------------------------------------------------------------- 05 里程碑
MILESTONES = [
("2026 Q3", "工艺库上线", "覆盖钣金、机加两条主线", True),
("2026 Q4", "接入 ERP", "报价直接落成订单", True),
("2027 Q1", "开放 API", "让客户在自己系统里报价", False),
("2027 Q2", "行业模板", "从制造扩到工程与装修", False),
]
# 时间轴列之间不留 gap轴线是每列自己那一段拼起来的列间一旦有间隙
# 轴就断成四截。列内文案靠右 padding 让出呼吸,轴线段照样贯穿。
TIMELINE_TEXT_INSET = 56
DOT = 28
def milestone_column(date, title, desc, reached):
"""一段时间轴。圆点与文案都左对齐,轴线从圆点向右延伸到下一个圆点。"""
if reached:
dot = rect(ids, "节点圆", width=DOT, height=DOT, cornerRadius=DOT // 2,
fill=solid("$c-accent"))
else:
dot = rect(ids, "节点圆", width=DOT, height=DOT, cornerRadius=DOT // 2,
fill=solid("$c-bg"), stroke=stroke("$c-accent", 4))
axis = row("轴段", [
dot,
rect(ids, "轴线", width="fill_container", height=4,
fill=solid("$c-border")),
], gap=0, align="center", height="fit_content")
# 上下两块文案带右内缩,轴段一律不写 padding —— 三个同类兄弟里只有两个
# 可归一化的 padding 值mixed-sibling-padding 因此不会误判(同一个值
# 出现两次,另一个不参与统计)。
head = col("节点日期", [
text(ids, "日期", date, 28, 600, "$c-accent", family=NUM,
line_height=1.2, spacing=0.5),
], gap=0, padding=[0, TIMELINE_TEXT_INSET, 0, 0])
body = col("节点文案", [
text(ids, "节点标题", title, 34, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "节点说明", desc, 26, 400, "$c-muted", family=CJK,
line_height=1.5),
], gap=10, padding=[0, TIMELINE_TEXT_INSET, 0, 0])
return col("里程碑", [head, axis, body], gap=28)
def roadmap():
s = slide("05 里程碑", gap=56)
s["children"] = [
page_head("接下来十二个月",
"前两步已经走完,这轮钱花在后两步上。"),
centered_body("时间轴区",
row("时间轴",
[milestone_column(*entry) for entry in MILESTONES],
gap=0, align="start")),
]
return s
# ---------------------------------------------------------------- 06 结尾
def closing():
lede = col("结语", [
accent_bar(),
text(ids, "结语标题", "如果你也觉得\n这事早该被解决", 88, 700,
"$c-ink", family=CJK, line_height=1.15),
text(ids, "结语副标题", "我们缺的不是订单,是把它做快的那笔钱。",
32, 400, "$c-muted", family=CJK, line_height=1.45),
], gap=36)
# 名字与两行联络方式分成两层,不是排版洁癖:三个平级文本一旦字号 32 /
# 30 / 30sibling-inconsistency 会把那个 32 当成手滑。分层之后 32 是
# 标题、30 是并列的两行,各自内部一致。
contact = col("联系方式", [
text(ids, "联系人", "陈知远 · 创始人", 32, 600, "$c-ink", family=CJK,
line_height=1.3),
col("联络方式", [
text(ids, "邮箱", "zhiyuan@qimingzz.com", 30, 500, "$c-accent",
family=NUM, line_height=1.4),
text(ids, "电话", "+86 138 0000 0000", 30, 400, "$c-muted",
family=NUM, line_height=1.4),
], gap=10, width="fit_content"),
], gap=18, width="fit_content")
# 二维码占位用 frame 而不是 rectangleframe 会递归渲染子节点,里面那行
# 提示才显示得出来frame 同时是合法的图片拖放目标,把二维码图片拖上去
# 就直接替换掉这个占位。
qr = frame(ids, "二维码占位", width=200, height=200, layout="vertical",
gap=0, alignItems="center", justifyContent="center",
cornerRadius=16, fill=solid("$c-accent-soft"),
stroke=stroke("$c-accent", 2))
qr["children"] = [
text(ids, "二维码提示", "扫码看 Demo", 26, 600, "$c-accent",
family=CJK, width="fit_content", growth="auto",
line_height=1.3),
]
card = row("联系卡", [contact, qr], gap=48, align="center",
justifyContent="space_between", padding=[48, 56],
cornerRadius=24, fill=solid("$c-surface"))
s = slide("06 结尾", gap=56, justify="space_between")
s["children"] = [lede, card]
return s
# 顶层 frame 必须显式写 x/y缺省时每帧都落在原点六页会叠成一页另外
# 五页藏在下面2026-08-02 实录)。横向 gap 与纵向 gap 的取值同 tpl_slides
# 纵向多留 240是给画布上固定在帧顶部的帧名标签让位——标签是屏幕空间的
# 不随缩放变小deck 缩到能整屏看时 120 文档像素只剩十几个屏幕像素。
BOARD_GAP_X = 120
BOARD_GAP_Y = BOARD_GAP_X + 240
BOARDS_PER_ROW = 3
def build():
boards = [cover(), problem(), solution(), metrics(), roadmap(), closing()]
for index, board in enumerate(boards):
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 17.30 c-ink on c-surface 13.98
# c-muted on c-bg 7.44 c-muted on c-surface 6.01
# c-accent on c-bg 5.86 c-accent on c-surface 4.73 ← 最低一对
# c-accent on c-accent-soft 4.90
# 数据页的 140px 大数字用的是 c-accent on c-surface = 4.73:1。换强调色时
# 先拿这一对量一遍:深底上亮色越纯,比值掉得越快。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "路演 deck · 深色 16:9")

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB