openpencil/templates/step0/_generators/tpl_infographic_data.py
Fini c998d551eb feat(editor): grow the template library to fifty-eight across six scenes
Every non-deck, non-web scene now holds ten templates: cards (spec
themes as living samples), infographics, tutorials, comparisons and
carousels (nine spec themes with per-set master-board rules), plus
two web landings whose 'generate from this' pins their measured
style guides — a basis the document swap used to drop before the
turn ever saw it. All fifty-eight ship generator-reproduced (sha256-
matched), audit-clean, with baked preview cards, and the scene tab
row pins Slides second-from-last and Web last. Two prompt-center
entries carry the web pair with re-rendered previews and honest
render provenance.

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

345 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""data-report-infographic.op — 数据结论信息长图1080×N 竖版)
「信息图」这一档和卡片的区别不是画幅,是**阅读方式**:卡片是一眼看完,
长图是从上往下滑着读完。所以这张图的结构是一条阅读动线 ——
深色页头(结论先行)→ 三个大数 → 横向对比条 → 构成占比 → 三条结论 → 出处
—— 而不是卡片那种「标题 + 要点 + 署名」的静态版式。
图表纪律dataviz在这张图里的具体落法
- **一个强调色,只给要说的那一条。** 对比条里只有第一名走青绿渐变,其
余四条统一走中性轨道色。五条都上色等于没上色。
- **不画图例,直接标在图上。** 数值贴在条的右端,占比标在色块下方,读
者的视线不需要在图和图例之间来回跳。
- **不画坐标轴和网格线。** 五条带标签的横条本来就自带比较基准,轴线只
是噪声。
- **数字用等宽感的 Inter文案用 Noto Sans SC。** 全图两个字族封顶。
硬契约:
- 内容距边缘 ≥80px这里 80
- 配色全部走 color_vars改主色只改 $c-accent / $c-accent-deep 两处
- 正文与背景对比度 ≥2.0(本配色最低一对见文件末尾注释)
- **CJK 行高比西文全线高 0.2**:页头大标题 1.2,区块标题 1.3,正文 1.7
- **CJK 负字距不超过 -0.02em**(汉字是满格设计,再负就笔画相撞);只有
西文数字沿用西文 display 的收紧
- 汉字走 Noto Sans SC数字与拉丁走 Inter —— 等价于中文排印规范里
「西文在前、中文在后」的 fallback 链,只是在 .op 里按节点写死
- 顶层 frame 必须显式写 x/y否则多帧会全部堆在原点
- 根高固定ROOT_H 是量出来的(见文件末尾),改内容后要重量一次
### 负约束(本模板明令不做的事)
- 不画坐标轴、网格线、图例。带标签的横条自带比较基准,其余都是噪声。
- 不给第二条数据上强调色。五条里只有第一名走渐变,「都强调 = 没强调」。
- 不用蓝紫渐变、霓虹线条、复杂背景纹理(廉价 AI 科技风的三件套)。
- 不用伪 3D 图标、不用 emoji 当图标、不用装饰性插画。
- 不靠压字号硬塞:信息密度高时加区块,不缩字。
- 不写 AI 套话(「赋能 / 洞察闭环 / 一站式」),结论写成能直接排进日程的动作。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, color_vars, frame, icon_font, linear, rect, solid,
text, write_doc)
ids = Ids()
VARS = color_vars({
"c-bg": "#F4F7F6",
"c-surface": "#FFFFFF",
# 页头与页脚的深底。近黑的墨绿而不是纯黑:它要和青绿强调色同一个色相家
# 族,长图从头到尾才像一份东西。
"c-band": "#0C2E2B",
"c-band-muted": "#9FBDB8",
"c-ink": "#0E2523",
"c-muted": "#5A736F",
"c-accent": "#0E9F8B",
# 小字压在浅底上时用的深一档。强调色本身在 #FFF 上只有 3.3:1够大数字
# 不够 24px 的胶囊文字 —— 分两个变量比让 lint 过关更重要。
"c-accent-deep": "#096A5D",
"c-accent-soft": "#DCF2ED",
# 对比条的中性轨道 / 非强调条。它是「其余四条」的颜色,也是底槽的颜色,
# 同一个值让「没被强调的部分」在视觉上退成一个整体。
"c-track": "#DDE7E5",
"c-track-2": "#B7C9C6",
"c-border": "#E1EAE8",
})
CJK = "Noto Sans SC"
NUM = "Inter"
W = 1080
EDGE = 80
INNER = W - EDGE * 2
# 对比条的三段固定宽度:标签 / 轨道 / 数值。三个兄弟统一写死像素而不是混用
# fill_container —— 混用会让轨道长度随标签字数变化,五条就对不齐了。
BAR_LABEL_W = 210
BAR_VALUE_W = 116
BAR_GAP = 24
BAR_TRACK_W = INNER - BAR_LABEL_W - BAR_VALUE_W - BAR_GAP * 2
BAR_H = 46
# 量出来的根高。内容全是 fit_content把根设成 fit_content 渲一次读 PNG 高
# 度,再把结果烤回来(同 saas-landing-orange 的 3240
ROOT_H = 2684
def band(name, *, fill, pad, gap, children, align="start"):
"""一个通栏区块。fill 决定它是不是一块有颜色的带 —— 结构容器不写 fill。"""
node = frame(ids, name, width="fill_container", height="fit_content",
layout="vertical", padding=pad, gap=gap, alignItems=align,
fill=fill)
node["children"] = children
return node
def col(name, children, *, gap=16, width="fill_container", align="start",
**props):
node = frame(ids, name, width=width, height="fit_content",
layout="vertical", gap=gap, alignItems=align, fill=[],
**props)
node["children"] = children
return node
def row(name, children, *, gap=24, align="center", width="fill_container",
**props):
node = frame(ids, name, width=width, height="fit_content",
layout="horizontal", gap=gap, alignItems=align, fill=[],
**props)
node["children"] = children
return node
def chip(label, *, bg, fg, size=24):
node = frame(ids, "胶囊", width="fit_content", height="fit_content",
layout="horizontal", padding=[10, 22], cornerRadius=999,
alignItems="center", justifyContent="center", fill=solid(bg))
node["children"] = [
text(ids, "胶囊文字", label, size, 600, fg, family=CJK,
width="fit_content", growth="auto", line_height=1.4),
]
return node
def section_head(title, note):
"""区块标题。一条短强调线 + 标题 + 一句说明,全图五处一模一样。"""
return col("区块头", [
rect(ids, "强调短线", width=72, height=8, cornerRadius=999,
fill=solid("$c-accent")),
text(ids, "区块标题", title, 46, 700, "$c-ink", family=CJK,
line_height=1.3),
text(ids, "区块说明", note, 27, 400, "$c-muted", family=CJK,
line_height=1.7),
], gap=16)
# ------------------------------------------------------------------ 01 页头
def header():
return band("01 页头", fill=solid("$c-band"), pad=[76, EDGE, 68, EDGE],
gap=26, children=[
chip("数据周报 · 2026 上半年", bg="$c-accent", fg="$c-band"),
text(ids, "主标题", "三个数字\n说清这半年", 76, 700, "$c-surface",
family=CJK, line_height=1.2, spacing=-1.4),
text(ids, "副标题", "全部口径与统计区间写在图片最下方,可直接转发。",
28, 400, "$c-band-muted", family=CJK, line_height=1.7),
])
# ------------------------------------------------------------------ 02 大数
KPIS = [
("2.4", "", "月均活跃创作者", "+62%"),
("18", "分钟", "首次上手到出图", "41%"),
("93", "%", "一次通过率", "+9pt"),
]
def kpi_card(value, unit, label, delta):
"""大数字卡。数值走强调色、单位退成中性 —— 一张卡里只有一个视觉重心。"""
value_row = row("数值", [
text(ids, "数值", value, 78, 700, "$c-accent-deep", family=NUM,
width="fit_content", growth="auto", line_height=1.0, spacing=-3),
text(ids, "单位", unit, 28, 600, "$c-muted", family=CJK,
width="fit_content", growth="auto", line_height=1.0),
], gap=8, align="end", width="fit_content")
card = col("大数卡", [
value_row,
text(ids, "指标名", label, 26, 600, "$c-ink", family=CJK,
line_height=1.4),
chip(delta, bg="$c-accent-soft", fg="$c-accent-deep", size=22),
], gap=16, padding=[32, 28], cornerRadius=20)
card["fill"] = solid("$c-surface")
card["stroke"] = {"thickness": 2, "fill": solid("$c-border")}
card["height"] = "fill_container"
return card
def kpis():
grid = row("大数网格", [kpi_card(*entry) for entry in KPIS], gap=20,
align="stretch")
return band("02 大数", fill=[], pad=[64, EDGE, 0, EDGE], gap=32, children=[
section_head("先看三个数",
"只挑能直接下结论的指标,其余都放在文末的口径说明里。"),
grid,
])
# ------------------------------------------------------------------ 03 对比
# (标签, 数值文案, 占满轨道的比例)。比例是画出来的长度,数值是写出来的事实,
# 两者必须自己对齐 —— 没有任何一层会替你检查它们是否一致。
BARS = [
("模板起稿", "4.6k", 1.00),
("空白起稿", "2.9k", 0.63),
("导入截图", "1.7k", 0.37),
("复制他人", "0.9k", 0.20),
("其他入口", "0.4k", 0.09),
]
def bar_row(label, value, ratio, highlight):
track = frame(ids, "轨道", width=BAR_TRACK_W, height=BAR_H,
layout="horizontal", alignItems="center", gap=0,
cornerRadius=BAR_H // 2, clipContent=True,
fill=solid("$c-track"))
track["children"] = [
rect(ids, "", width=max(round(BAR_TRACK_W * ratio), BAR_H),
height=BAR_H, cornerRadius=BAR_H // 2,
# 90° = 自左向右:深端坐在起点,条尾收到主强调色。只有第一名用
# 渐变,其余走中性 —— 这就是「一个强调色只给要说的那一条」。
fill=(linear(90, [(0.0, "$c-accent-deep"), (1.0, "$c-accent")])
if highlight else solid("$c-track-2"))),
]
return row("对比行", [
text(ids, "条标签", label, 27, 500, "$c-ink", family=CJK,
width=BAR_LABEL_W, line_height=1.4),
track,
text(ids, "条数值", value, 30, 700,
"$c-accent-deep" if highlight else "$c-muted", family=NUM,
width=BAR_VALUE_W, align="right", line_height=1.4),
], gap=BAR_GAP)
def comparison():
rows = [bar_row(label, value, ratio, index == 0)
for index, (label, value, ratio) in enumerate(BARS)]
return band("03 对比", fill=[], pad=[72, EDGE, 0, EDGE], gap=32, children=[
section_head("新图是从哪儿开始的",
"近 30 天新建文档的起稿方式,按数量从多到少。"),
col("对比列表", rows, gap=22),
])
# ------------------------------------------------------------------ 04 构成
SPLIT = [
("社交卡片", "52%", 0.52, "$c-accent-deep"),
("演示文稿", "31%", 0.31, "$c-accent"),
("其他", "17%", 0.17, "$c-track-2"),
]
def composition():
stacked = frame(ids, "占比条", width="fill_container", height=56,
layout="horizontal", gap=0, cornerRadius=28,
clipContent=True, fill=solid("$c-track"))
# 最后一段吃掉四舍五入的余数:三段各自 round 之后总和会比 INNER 少 1-2
# px底槽就从右端露出一道细缝看起来像「占比没加满 100%」。
widths = [round(INNER * ratio) for _, _, ratio, _ in SPLIT]
widths[-1] = INNER - sum(widths[:-1])
stacked["children"] = [
rect(ids, f"占比段 · {name}", width=width, height=56, fill=solid(color))
for (name, _, _, color), width in zip(SPLIT, widths)
]
legend_items = []
for name, pct, _, color in SPLIT:
legend_items.append(row("图例项", [
rect(ids, "色点", width=18, height=18, cornerRadius=9,
fill=solid(color)),
text(ids, "图例名", name, 26, 500, "$c-ink", family=CJK,
width="fit_content", growth="auto", line_height=1.4),
text(ids, "图例值", pct, 26, 700, "$c-muted", family=NUM,
width="fit_content", growth="auto", line_height=1.4),
], gap=12, width="fit_content"))
return band("04 构成", fill=[], pad=[72, EDGE, 0, EDGE], gap=32, children=[
section_head("这些图最后变成了什么",
"按导出成品分类。一份文档只计一次,取最后一次导出。"),
col("占比区", [
stacked,
row("图例", legend_items, gap=40, width="fill_container"),
], gap=24),
])
# ------------------------------------------------------------------ 05 结论
TAKEAWAYS = [
"模板起稿是空白起稿的 1.6 倍,入口比数量更值得投入。",
"首次出图时间压到 18 分钟之后,一次通过率才开始往上走。",
"社交卡片占了一半以上的成品,但模板供给还集中在演示文稿。",
]
def takeaways():
items = []
for line in TAKEAWAYS:
items.append(row("结论项", [
icon_font(ids, "对勾", "check-circle", 30, "$c-accent-deep"),
text(ids, "结论文字", line, 28, 500, "$c-ink", family=CJK,
line_height=1.7),
], gap=18, align="start"))
panel = col("结论面板", items, gap=22, padding=[40, 36], cornerRadius=24)
panel["fill"] = solid("$c-accent-soft")
return band("05 结论", fill=[], pad=[72, EDGE, 76, EDGE], gap=32, children=[
section_head("那么该做什么",
"三条能直接排进下个季度的动作,其余留给评论区。"),
panel,
])
# ------------------------------------------------------------------ 06 页脚
def footer():
return band("06 页脚", fill=solid("$c-band"), pad=[44, EDGE], gap=12,
children=[
text(ids, "出处", "数据口径2026-01-01 至 2026-06-30去重后按文档计。",
24, 400, "$c-band-muted", family=CJK, line_height=1.6),
row("署名行", [
text(ids, "账号名", "@ 你的账号名", 26, 600, "$c-surface",
family=CJK, width="fit_content", growth="auto",
line_height=1.4),
text(ids, "更新说明", "每月一张数据图", 24, 400, "$c-band-muted",
family=CJK, width="fit_content", growth="auto",
line_height=1.4),
], gap=16, width="fill_container"),
])
def build():
page = frame(ids, "数据结论长图", width=W, height=ROOT_H, layout="vertical",
gap=0, fill=solid("$c-bg"), clipContent=True)
page["children"] = [header(), kpis(), comparison(), composition(),
takeaways(), footer()]
page["x"], page["y"] = 0, 0
return [page]
# 对比度WCAG 相对亮度比op-design-lint 的门槛是 2.0;数值实测):
# c-surface on c-band 14.57 c-band-muted on c-band 7.25
# c-ink on c-bg 14.90 c-muted on c-bg 4.73
# c-ink on c-surface 16.06 c-muted on c-surface 5.10
# c-accent-deep on c-surface 6.50 c-band on c-accent 4.41
# c-accent-deep on c-accent-soft 5.56 c-ink on c-accent-soft 13.74
# 最低一对 4.73 仍高于 WCAG AA 的 4.5。强调色本身 (#0E9F8B) 只出现在 78px
# 的大数、色块和 5px 的短线上,从不承载小字 —— 它在 #FFF 上只有 3.31。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "数据结论信息长图")