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

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

327 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
"""milestone-timeline-infographic.op — 时间线大事记长图1080×N 竖版)
信息图这一档的第四张。前三张回答「看数字」「学方法」「抄清单」,这一张回
答**「它是怎么走到今天的」** —— 唯一一张以时间为轴的长图。
### 最近邻差异(为什么它不是 steps-flow 换个皮)
steps-flow 也是有序的,两者的分界线是**时态**,而时态又决定了三处结构:
- **steps 讲将来timeline 讲过去。** steps 每条是「你去做」,配的是耗时
上限timeline 每条是「那时发生了」,配的是年月。所以 timeline 有一列
steps 没有的东西:年份刻度列。
- **steps 不画连接线timeline 必须画。** steps 的取舍写在它自己的负约
束里 —— 跨卡片的竖线要写死像素。这张图绕开了那个陷阱:轴线不跨卡片,
它是**每一行内部的一列**`alignItems="stretch"` + `height:
fill_container`),行与行 gap=0 才连成一条。改文案时行长了,那一段轴
自己就长了。这是同一个问题的另一个解,不是同一个解。
- **steps 是浅底暖橙timeline 是深底靛蓝,且页头反相。** 前三张都是
「深色页头 + 浅底正文」,这张是「**浅色页头 + 深底正文**」—— 整档并
排时,它是唯一一张反着来的。
### 配色推导(采样 → 收敛 → 论证)
- **采样**:从「夜里回看一年」这件事上取色 —— 深靛蓝是天光落尽之后的
颜色,年份点像刻度上的星。不从科技模板里抽蓝紫。
- **收敛**:一个色相(靛蓝 ~235°+ 一条中性偏冷的明度序列。强调色只有
一个 #7C93FF只出现在年份、轴点和短线上。
- **论证**:时间线的层级天然由**位置**给(越上越早),不需要颜色再分一
次层。所以颜色只做两件事:把「轴」和「正文」分开,把「已发生」和「下
一站」分开(实心点 vs 空心点)。
### 负约束(本模板明令不做的事)
- **不用渐变。** 深底 + 蓝紫色相是「廉价 AI 科技风」的高发区,渐变一上
就掉进去。全图只有实色块。
- 不画霓虹发光线、扫描线、网格暗纹、粒子背景。深底的可读性靠留白给。
- 不给轴线加箭头、不给节点加光晕、不做时间轴的 3D 透视。
- 一行只记一件事:写不下就拆成两行,不缩字号(信息密度高时加行)。
- 不用 emoji 当图标、不用装饰性插画。
- 不写 AI 套话(「里程碑式跨越 / 全新征程」),每一条都写具体做了什么、
以及那件事之后改了什么做法。
硬契约:
- 内容距边缘 ≥80px这里 80
- 配色全部走 color_vars改主色只改 $c-accent 一处(轴线 $c-rail 是它
的暗一档,换主色时要一起量)
- 正文与背景对比度 ≥2.0(本配色最低一对见文件末尾注释)
- **CJK 行高比西文全线高 0.2**:页头大标题 1.2,区块标题 1.3,正文 1.7
- **CJK 负字距不超过 -0.02em**;只有西文年份沿用西文 display 的收紧
- 汉字走 Noto Sans SC数字与拉丁走 Inter
- 顶层 frame 必须显式写 x/y否则多帧会全部堆在原点
- 根高固定ROOT_H 是量出来的(见文件末尾),改内容后要重量一次
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import (Ids, color_vars, frame, icon_font, rect, solid, text,
write_doc)
ids = Ids()
VARS = color_vars({
"c-bg": "#0E1330",
"c-surface": "#1A2150",
"c-ink": "#F1F3FF",
"c-muted": "#A6AEDC",
"c-accent": "#7C93FF",
# 反相页头那一块浅底,以及它上面的深字 / 次级字。整张图只有页头是亮
# 的,所以这三个变量只服务那一处,不参与正文。
"c-band": "#E7EAFF",
"c-band-ink": "#131A44",
"c-band-muted": "#4A5490",
# 轴线。强调色的暗一档:轴要看得见但不能和年份抢 —— 用 c-accent 本身画
# 3px 竖线,整条轴会比年份还亮(实测第一版如此)。
"c-rail": "#5766B5",
})
CJK = "Noto Sans SC"
NUM = "Inter"
W = 1080
EDGE = 80
# CJK 行高阶梯(西文 +0.2)。全篇只用这三档。
LH_DISPLAY, LH_HEAD, LH_BODY = 1.2, 1.3, 1.7
# 轴的三段固定宽度:年份列 / 轴列 / 事件卡。前两列写死像素,事件卡吃剩下
# 的 —— 年份列若用 fit_content「2020.03」和「现在」会算出两种宽度,整条
# 轴就歪了。
YEAR_W = 150
AXIS_W = 38
ROW_GAP = 22
# 轴点所在的那一格高度。它要和年份那行文字的视觉中线对齐:年份 40px、行高
# 1.0,上方留 6px所以 46。
DOT_BOX_H = 46
DOT_D = 22
# 量出来的根高(做法同另外三张:根设 fit_content 渲一次读 PNG 高度)。
ROOT_H = 3018
# (年月, 标签, 那时做了什么, 之后改了什么做法)
EVENTS = [
("2020.03", "起点", "把第一篇发出去",
"写了两周、改了七版。起作用的是按下发布那一下。"),
("2020.11", "转折", "把更新时间写进日历",
"固定每周三更新之后,比靠状态更新多撑了半年。"),
("2021.06", "第一次被看见", "一篇只回答一个问题",
"放弃「什么都写一点」,一篇讲清一件事,转发的人立刻多起来。"),
("2022.02", "开始省时间", "把重复的版式存成模板",
"同一个版式做到第八次才存模板。第二次就该存。"),
("2023.05", "第一笔收入", "先做出来,再定价",
"免费发满 40 期之后才收费,报价按自己做一期的实际时长算。"),
("2024.09", "停更三个月", "停之前先发一条说明",
"说清楚什么时候回来,回来时读者还在 —— 消失才是真的掉人。"),
("2026.01", "现在", "一周一张,一天只干一件",
"选题、做图、发布拆到三天,每天只碰一件事,出错的地方少了一多半。"),
]
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",
height="fit_content", **props):
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, alignItems=align, fill=[], **props)
node["children"] = children
return node
def row(name, children, *, gap=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=LH_HEAD),
text(ids, "区块说明", note, 27, 400, "$c-muted", family=CJK,
line_height=LH_BODY),
], gap=16)
# ------------------------------------------------------------------ 01 页头
def header():
"""唯一的浅色区块。整档四张里只有这张的页头是亮的 —— 反相是它的标识。"""
return band("01 页头", fill=solid("$c-band"), pad=[76, EDGE, 68, EDGE],
gap=26, children=[
chip("大事记 · 六年七件事", bg="$c-band-ink", fg="$c-band"),
text(ids, "主标题", "一条线\n看完这六年", 76, 700, "$c-band-ink",
family=CJK, line_height=LH_DISPLAY, spacing=-1.4),
text(ids, "副标题", "每一行都记一件具体做过的事,以及那之后改了什么。",
28, 400, "$c-band-muted", family=CJK, line_height=LH_BODY),
])
# ------------------------------------------------------------------ 02 轴
def axis_column(*, hollow=False, tail=True):
"""年份与卡片之间那一列:一个点 + 一条竖线。
竖线写 `height: fill_container`,而这一列在 `alignItems="stretch"` 的
行里被拉到整行高 —— 于是卡片长高多少,这一段轴就长高多少。行与行之间
gap 必须是 0否则轴会断成七截这就是 steps-flow 放弃画竖线的那个问
题,在「轴属于行内部」这个前提下才有解)。
"""
dot = rect(ids, "轴点", width=DOT_D, height=DOT_D, cornerRadius=999,
fill=[] if hollow else solid("$c-accent"))
if hollow:
dot["stroke"] = {"thickness": 4, "fill": solid("$c-accent")}
dot_box = frame(ids, "轴点格", width=AXIS_W, height=DOT_BOX_H,
layout="horizontal", alignItems="center",
justifyContent="center", fill=[])
dot_box["children"] = [dot]
line = rect(ids, "轴线", width=3, height="fill_container",
fill=solid("$c-rail") if tail else [])
return col("轴列", [dot_box, line], gap=0, width=AXIS_W,
height="fill_container", align="center")
def event_row(when, tag, title, desc, *, last=False):
year_col = col("年份列", [
text(ids, "年月", when, 38, 700, "$c-accent", family=NUM,
align="right", line_height=1.0, spacing=-1),
text(ids, "阶段标签", tag, 23, 500, "$c-muted", family=CJK,
align="right", line_height=1.4),
], gap=10, width=YEAR_W, align="end")
card = col("事件卡", [
text(ids, "事件标题", title, 34, 600, "$c-ink", family=CJK,
line_height=LH_HEAD),
text(ids, "事件说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=LH_BODY),
], gap=12, padding=[26, 30], cornerRadius=20)
card["fill"] = solid("$c-surface")
# 行间距做在卡片外层的下内边距上,而不是行与行的 gap 上 —— gap 会在轴
# 上留出七道缺口。
slot = col("事件槽", [card], gap=0, padding=[0, 0, 0 if last else 30, 0])
return row("轴行", [year_col, axis_column(tail=not last), slot],
gap=ROW_GAP, align="stretch")
def next_row():
"""收尾那一行:空心点、无轴尾。它标的是「还没发生」。"""
year_col = col("年份列", [
text(ids, "年月", "下一站", 34, 700, "$c-muted", family=CJK,
align="right", line_height=1.0),
], gap=10, width=YEAR_W, align="end")
card = col("待续卡", [
text(ids, "待续标题", "这条线可以换成你自己的", 34, 600, "$c-ink",
family=CJK, line_height=LH_HEAD),
text(ids, "待续说明",
"换掉年份和事件即可,复制一行轴会自己接上。",
27, 400, "$c-muted", family=CJK, line_height=LH_BODY),
], gap=12, padding=[26, 30], cornerRadius=20)
card["fill"] = []
card["stroke"] = {"thickness": 2, "fill": solid("$c-rail"),
"dashPattern": [10, 8]}
return row("待续行", [year_col, axis_column(hollow=True, tail=False), card],
gap=ROW_GAP, align="stretch")
def timeline():
rows = [event_row(*entry, last=False) for entry in EVENTS]
rows.append(next_row())
return band("02 时间轴", fill=[], pad=[68, EDGE, 0, EDGE], gap=36,
children=[
section_head("从第一篇到现在",
"按时间从早到晚。跳着看也行,每一行都能单独读完。"),
col("", rows, gap=0),
])
# ------------------------------------------------------------------ 03 收尾
def closing():
lines = [
("把日期写下来,比记住它更有用。", "calendar-check"),
("六年里真正改变结果的只有三四件事,其余都是维持。", "filter"),
]
items = []
for line, glyph in lines:
items.append(row("收尾项", [
icon_font(ids, "图标", glyph, 28, "$c-accent"),
text(ids, "收尾文字", line, 27, 500, "$c-ink", family=CJK,
line_height=LH_BODY),
], gap=16, align="start"))
panel = col("收尾面板", items, gap=18, padding=[36, 34], cornerRadius=22)
panel["fill"] = solid("$c-surface")
return band("03 收尾", fill=[], pad=[64, EDGE, 64, EDGE], gap=32,
children=[
section_head("回看时才看得出来的两件事",
"当时都不觉得是转折,是排成一条线之后才认出来的。"),
panel,
])
# ------------------------------------------------------------------ 04 页脚
def footer():
return band("04 页脚", fill=solid("$c-band"), pad=[44, EDGE], gap=12,
children=[
text(ids, "口径", "时间以公开发布的那天计,同月多件只留改变做法的那件。",
24, 400, "$c-band-muted", family=CJK, line_height=1.6),
row("署名行", [
text(ids, "账号名", "@ 你的账号名", 26, 600, "$c-band-ink",
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(), timeline(), closing(), footer()]
page["x"], page["y"] = 0, 0
return [page]
# 对比度WCAG 相对亮度比op-design-lint 的门槛是 2.0;数值实测):
# c-ink on c-bg 16.46 c-muted on c-bg 8.41
# c-ink on c-surface 13.80 c-muted on c-surface 7.05
# c-accent on c-bg 6.47 c-accent on c-surface 5.43
# c-band-ink on c-band 13.99 c-band-muted on c-band 5.95
# c-band-ink on c-accent 5.94 c-rail on c-bg 3.44
# 承载文字的最低一对是 5.43surface 卡上的强调色,实际只用在 38px 年份
# 上);正文最低是 7.05。c-rail 是 3px 轴线与虚线描边,非文字图形按 3.0
# 量3.44 过线。换主色时先量 c-accent/c-bg 与 c-rail/c-bg 这两对。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "时间线大事记长图")