feat(editor): add the six-page presentation template

The `slides` scene had a filter chip and no content. This fills it with a
deck that covers the shapes a report actually needs — cover, agenda,
three-point argument, metrics, chart with its takeaway spelled out, and a
closing — so a user replaces text rather than building structure.

Typography and layout follow the `slides` domain contract rather than
being eyeballed: 1920x1080 frames at a fixed size (never fit_content —
projector aspect is a hard constraint), 120px safe margins, body at
26-34, titles at 64, and the metric numbers at 140 so each data slide has
exactly one visual anchor. Frames are generated through `oplib` against
the canonical schema, like the other templates, so they stay reproducible.

The card baker learns to tile multiple renders into a grid. A 16:9 deck
composited as a 1x6 strip is a 10:1 image that shrinks each slide to
~100px inside the 640x400 card — visible as noise, not as slides. Column
count is picked to land nearest the card's own aspect, which puts this
deck at 3x2 and leaves each slide legible.

Also ignores `__pycache__` repo-wide; the generators are run in place and
were leaving bytecode next to their sources.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-08-02 11:00:34 +08:00
parent 20cfbddd4b
commit 048d3cf36f
14 changed files with 1971 additions and 7 deletions

2
.gitignore vendored
View file

@ -58,5 +58,7 @@ Cargo.lock.merge-conflict
**/locator-signing-key*.json
tools/__pycache__/
__pycache__/
*.pyc
openpencil-docs
output

View file

@ -33,6 +33,18 @@ frames = 5
frame_width = 1080
frame_height = 1440
[[template]]
id = "slide-deck"
scene = "slides"
title_key = "sceneTemplate.item.slideDeck.title"
title_fallback = "演示文稿 · 六页"
summary_key = "sceneTemplate.item.slideDeck.summary"
summary_fallback = "封面、目录、要点、数据、图表和结尾16:9 投影比例,替换文案即可上台。"
tags = ["PPT", "演示", "汇报", "幻灯片", "slides", "deck", "presentation"]
frames = 6
frame_width = 1920
frame_height = 1080
[[template]]
id = "before-after"
scene = "comparison"

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,7 @@ const SCREENSHOT_TUTORIAL_OP: &str =
include_str!("../assets/scene_templates/screenshot-tutorial.op");
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");
/// Return the embedded document JSON for a template id.
///
@ -38,6 +39,7 @@ pub fn scene_template_document(template_id: &str) -> Option<&'static str> {
"screenshot-tutorial" => Some(SCREENSHOT_TUTORIAL_OP),
"knowledge-carousel" => Some(KNOWLEDGE_CAROUSEL_OP),
"before-after" => Some(BEFORE_AFTER_OP),
"slide-deck" => Some(SLIDE_DECK_OP),
_ => None,
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,5 +1,6 @@
#!/usr/bin/env bash
# 渲染三套模板的预览图每帧一张scale 2+ 整页拼合总览scale 1
# 渲染场景模板的预览图每帧一张scale 2+ 整页拼合总览scale 1
# 卡片缩略图由 scene_preview_cards.py 从这些渲染产物再烤一层。
set -euo pipefail
R=/Users/fini/workspace/openpencil
BIN=$R/target/release/openpencil-desktop
@ -7,7 +8,7 @@ OUT=$R/templates/step0/previews
TMP=$(mktemp -d)
mkdir -p "$OUT"
for t in screenshot-tutorial knowledge-carousel before-after; do
for t in screenshot-tutorial knowledge-carousel before-after slide-deck; 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

@ -37,12 +37,20 @@ INSET = 12
# (card id, source preview). The overview render is preferred wherever a
# template has several frames: what makes a multi-page template legible at
# thumbnail size is seeing that it HAS pages, not reading one of them.
# (card id, source). A string names one render; a list is tiled into a grid
# whose column count is chosen to land near the card's own aspect — a 16:9
# deck laid out 1x6 is a 10:1 strip that shrinks each slide to ~100px inside
# the card, which reads as noise rather than as slides.
CARDS = [
("screenshot-tutorial", "screenshot-tutorial-overview.png"),
("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)]),
]
# Gap between tiles, in source pixels — scaled down with everything else.
TILE_GAP = 48
def background_colour(image: Image.Image) -> tuple[int, int, int]:
"""The document's own backdrop, sampled from the render's corners.
@ -64,8 +72,45 @@ def background_colour(image: Image.Image) -> tuple[int, int, int]:
return (245, 245, 245)
def bake(source: pathlib.Path) -> Image.Image:
image = Image.open(source)
def grid_columns(tile_aspect: float, count: int) -> int:
"""Column count whose tiled aspect sits closest to the card's.
Chosen rather than fixed so a 16:9 deck tiles 3x2 while a 3:4 carousel
tiles wide the goal is filling the card, not a particular shape.
"""
card_aspect = CARD_W / CARD_H
best, best_error = count, float("inf")
for columns in range(1, count + 1):
rows = -(-count // columns)
aspect = (columns * tile_aspect) / rows
error = abs(aspect - card_aspect)
if error < best_error:
best, best_error = columns, error
return best
def tile(sources: list[pathlib.Path]) -> Image.Image:
images = [Image.open(path).convert("RGB") for path in sources]
width = max(i.width for i in images)
height = max(i.height for i in images)
columns = grid_columns(width / height, len(images))
rows = -(-len(images) // columns)
canvas = Image.new(
"RGB",
(
columns * width + (columns - 1) * TILE_GAP,
rows * height + (rows - 1) * TILE_GAP,
),
background_colour(images[0]),
)
for index, image in enumerate(images):
column, row = index % columns, index // columns
canvas.paste(image, (column * (width + TILE_GAP), row * (height + TILE_GAP)))
return canvas
def bake(source: pathlib.Path | list[pathlib.Path]) -> Image.Image:
image = tile(source) if isinstance(source, list) else Image.open(source)
canvas = Image.new("RGB", (CARD_W, CARD_H), background_colour(image))
fitted = image.convert("RGB")
fitted.thumbnail((CARD_W - 2 * INSET, CARD_H - 2 * INSET), Image.LANCZOS)
@ -86,11 +131,15 @@ def main() -> int:
failed = False
for card_id, source_name in CARDS:
source = SRC / source_name
if not source.exists():
print(f"missing source: {source}", file=sys.stderr)
names = source_name if isinstance(source_name, list) else [source_name]
sources = [SRC / name for name in names]
missing = [path for path in sources if not path.exists()]
if missing:
for path in missing:
print(f"missing source: {path}", file=sys.stderr)
failed = True
continue
source = sources if isinstance(source_name, list) else sources[0]
target = DST / f"{card_id}.jpg"
if args.check:
status = "ok" if target.exists() else "MISSING"

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""slide-deck.op — 16:9 演示文稿模板(封面 + 目录 + 要点 + 数据 + 图表 + 结尾)
排版遵循 skills/domains/slides.md 的硬契约
- 每帧 1920×1080内容距边缘 100px
- 正文 24 28-32标题 40关键数字 80-200
- 行高标题 1.1-1.2正文放宽
- 最多 2 个字体族靠字重而非字号数量做层级
- 2-3 个主色 + 中性色强调色只用于强调
"""
import sys
from oplib import (Ids, color_vars, frame, rect, solid, stroke, text,
write_doc)
ids = Ids()
VARS = color_vars({
"c-bg": "#FFFFFF",
"c-surface": "#F4F6F9",
"c-ink": "#0B1220",
"c-muted": "#5A6B85",
"c-accent": "#2F5BEA",
"c-accent-soft": "#E4EAFD",
"c-border": "#DCE2EC",
})
# 幻灯片尺寸与安全区。EDGE 同时是每帧的 padding —— 契约要求 ≥100。
W, H = 1920, 1080
EDGE = 120
def slide(name, *, fill="$c-bg", gap=48, justify="start"):
"""一帧幻灯片。固定 1920×1080绝不 fit_content投影比例是硬约束。"""
node = frame(ids, name, width=W, height=H, layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
fill=solid(fill), clipContent=True)
node["children"] = []
return node
def row(name, *, gap=40, align="start", width="fill_container",
height="fit_content", fill=None, **props):
# `fill=None` means a transparent structural wrapper; an explicit fill is
# what makes a row a painted surface (card, panel).
node = frame(ids, name, width=width, height=height, layout="horizontal",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = []
return node
def col(name, *, gap=16, width="fill_container", height="fit_content",
fill=None, **props):
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, fill=fill or [], **props)
node["children"] = []
return node
def eyebrow(label):
"""小标签。ALL CAPS 只在这种小标签上使用(契约允许的唯一例外)。"""
node = frame(ids, "标签", width="fit_content", height="fit_content",
layout="horizontal", padding=[8, 18], cornerRadius=999,
fill=solid("$c-accent-soft"))
node["children"] = [
text(ids, "标签文字", label, 24, 600, "$c-accent",
width="fit_content", spacing=1.2),
]
return node
def accent_bar(width=120, height=8):
return rect(ids, "强调条", width=width, height=height, cornerRadius=999,
fill=solid("$c-accent"))
def cover():
s = slide("01 封面", justify="center", gap=40)
body = col("封面文案", gap=32)
body["children"] = [
eyebrow("2026 · 季度汇报"),
text(ids, "主标题", "把复杂的事,讲成一句话", 104, 700, "$c-ink",
line_height=1.15),
text(ids, "副标题", "副标题写清楚这次要让听众记住的那个结论。", 34, 400,
"$c-muted", line_height=1.4),
accent_bar(),
]
meta = row("落款", gap=32, align="center")
meta["children"] = [
text(ids, "演讲者", "演讲者姓名 · 团队", 28, 500, "$c-ink",
width="fit_content"),
text(ids, "日期", "2026.08", 28, 400, "$c-muted", width="fit_content"),
]
s["children"] = [body, meta]
return s
def agenda():
entries = [
("01", "背景与问题", "我们面对的现状是什么"),
("02", "解决方案", "做了哪三件关键的事"),
("03", "结果与数据", "带来了多少可衡量的变化"),
("04", "下一步", "接下来三个月的计划"),
]
s = slide("02 目录")
s["children"].append(text(ids, "页标题", "目录", 64, 700, "$c-ink",
line_height=1.15))
grid = row("目录网格", gap=32)
for no, title, desc in entries:
item = col("目录项", gap=14, padding=[36, 32], cornerRadius=20,
fill=solid("$c-surface"), height="fill_container")
item["children"] = [
text(ids, "序号", no, 40, 700, "$c-accent", width="fit_content",
line_height=1.1),
text(ids, "条目标题", title, 34, 600, "$c-ink", line_height=1.25),
text(ids, "条目说明", desc, 26, 400, "$c-muted", line_height=1.5),
]
grid["children"].append(item)
s["children"].append(grid)
return s
def points():
entries = [
("更快", "把等待时间从 8 秒压到 1.2 秒,用户不再中途离开。"),
("更稳", "关键路径的失败率下降到千分之一以内。"),
("更省", "同样的任务量,只用原来一半的算力预算。"),
]
s = slide("03 要点")
head = col("页头", gap=16)
head["children"] = [
text(ids, "页标题", "我们做了三件事", 64, 700, "$c-ink",
line_height=1.15),
text(ids, "页副标题", "每一件都能单独讲清楚,也都能被验证。", 30, 400,
"$c-muted", line_height=1.4),
]
grid = row("要点网格", gap=36)
for index, (title, desc) in enumerate(entries, 1):
item = col("要点", gap=20, padding=[40, 36], cornerRadius=20,
height="fill_container", fill=solid("$c-bg"),
stroke=stroke("$c-border", 2))
item["children"] = [
text(ids, "要点序号", f"0{index}", 32, 700, "$c-accent",
width="fit_content", line_height=1.1),
text(ids, "要点标题", title, 40, 600, "$c-ink", line_height=1.2),
text(ids, "要点说明", desc, 28, 400, "$c-muted", line_height=1.55),
]
grid["children"].append(item)
s["children"] = [head, grid]
return s
def metrics():
entries = [
("1.2s", "首屏加载", "较上季度 -85%"),
("99.9%", "关键路径成功率", "连续 90 天达标"),
("2.1x", "人均处理量", "同等人力下"),
]
s = slide("04 数据")
s["children"].append(text(ids, "页标题", "结果是可衡量的", 64, 700,
"$c-ink", line_height=1.15))
grid = row("数据网格", gap=36, height="fill_container", align="stretch")
for value, label, delta in entries:
item = col("数据卡", gap=18, padding=[56, 44], cornerRadius=24,
height="fill_container", justifyContent="center",
fill=solid("$c-surface"))
item["children"] = [
# 关键数字:契约允许 80-200这里取 140 让它成为唯一的视觉锚点。
text(ids, "数值", value, 140, 700, "$c-accent", line_height=1.05),
text(ids, "指标名", label, 32, 600, "$c-ink", line_height=1.25),
text(ids, "变化", delta, 26, 400, "$c-muted", line_height=1.4),
]
grid["children"].append(item)
s["children"].append(grid)
return s
def chart():
s = slide("05 图表")
head = col("页头", gap=16)
head["children"] = [
text(ids, "页标题", "趋势说明结论", 64, 700, "$c-ink",
line_height=1.15),
text(ids, "页副标题", "把图表要说明的那句话写在这里,别让听众自己找。",
30, 400, "$c-muted", line_height=1.4),
]
body = row("图表区", gap=48, height="fill_container", align="stretch")
# 图表占位:一组等宽柱子。替换成真实数据时改高度即可,不必重建结构。
plot = frame(ids, "图表占位", width="fill_container",
height="fill_container", layout="horizontal", gap=28,
alignItems="end", padding=[40, 40], cornerRadius=24,
fill=solid("$c-surface"))
plot["children"] = [
rect(ids, f"{i + 1}", width="fill_container", height=height,
cornerRadius=12,
fill=solid("$c-accent" if i == 4 else "$c-accent-soft"))
for i, height in enumerate([180, 250, 220, 330, 420])
]
notes = col("图表说明", gap=24, width=520)
for title, desc in [
("持续增长", "连续五个周期上升,没有出现回落。"),
("拐点在第四期", "投入的三项改动都在这一期生效。"),
("可以外推", "按当前斜率,下季度可达成目标线。"),
]:
item = col("说明项", gap=8)
item["children"] = [
text(ids, "说明标题", title, 30, 600, "$c-ink", line_height=1.25),
text(ids, "说明正文", desc, 26, 400, "$c-muted", line_height=1.55),
]
notes["children"].append(item)
body["children"] = [plot, notes]
s["children"] = [head, body]
return s
def closing():
s = slide("06 结尾", fill="$c-ink", justify="center", gap=40)
body = col("结尾文案", gap=32)
body["children"] = [
accent_bar(),
text(ids, "结语", "谢谢,欢迎提问。", 96, 700, "#FFFFFF",
line_height=1.15),
text(ids, "联系方式", "name@example.com · 团队主页", 32, 400,
"#9FB0CC", line_height=1.4),
]
s["children"] = [body]
return s
def build():
return [cover(), agenda(), points(), metrics(), chart(), closing()]
if __name__ == "__main__":
dst = sys.argv[1]
write_doc(dst, VARS, build(), "演示文稿 · 16:9 模板")

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB