eink-deck · 通用 eink 网页 PPT 引擎
这是什么
把「大纲 Markdown」渲染成单文件横向翻页 HTML PPT(浏览器放映、B 键静态、WebGL 水墨背景、主题可配)。引擎/模板/示例全部随本 skill 自带,在任何目录、任何专题都能用——不依赖任何具体工作区。
- 默认主题
ink-classic(墨水经典);内置 5 套预设含kraft牛皮纸(深墨#2a1e13/ 纸#eedfc7/ 朱砂#a8331f,三色体系,禁用 emoji——风格约定、引擎 validate 只计数不拦截;图标一律用 Lucide<ico=name>),见下「主题与配色」。 - 产物:
<输出目录>/index.html(单文件)
大纲目录怎么定(三级回退 · engine/_defaults.py)
golden_check.py / render_baseline.py 要找「16 讲实纲目录」时才用到,三处按序取第一个命中的:
- 命令行:
--outlines <目录>(优先级最高,端到端已实测可切换); - 环境变量:
EINK_OUTLINES_DIR; - 内置默认:
engine/_defaults.py的DEFAULT_OUTLINES_DIR。
该默认值是唯一源,两个脚本都从这里读,不各写一遍——历史上两边各写同一条本机绝对路径,属于「复制即漂移」。_tools/check_drift.py --strict 的 D2 项看守「不得再把资料库绝对路径写进脚本」,D1 项看守默认主题。
主题与配色(核心能力 · 5 套预设 + 默认 ink-classic)
本技能把"背景/字体配色"全部抽成主题,不再写死任何专题色。换主题只改一行大纲元数据或一条命令行参数,版式逻辑零改动。
| 主题键 | 名称 | 墨(ink) | 纸(paper) | 强调(accent) | 字体基调 |
|---|---|---|---|---|---|
ink-classic | 墨水经典(默认) | #0a0a0b | #f1efea | #b23a2e 编辑红 | 衬线 |
indigo | 靛蓝瓷 | #0a1f3d | #f1f3f5 | #d98324 暖琥珀 | 无衬线 |
forest | 森林墨 | #1a2e1f | #f5f1e8 | #b8842f 金 | 衬线 |
kraft | 牛皮纸 | #2a1e13 | #eedfc7 | #a8331f 朱砂红 | 衬线 |
dune | 沙丘 | #1f1a14 | #f0e6d2 | #b5654a 陶土 | 衬线 |
怎么选主题(优先级:命令行 > 大纲字段 > 默认):
- 大纲元数据(推荐):顶部写
- 主题: indigo(键也认theme:);- 字体: sans可单独切字体基调(serif/sans),不写则用主题默认。 - 命令行覆盖:
render_outline.py 大纲.md 输出 --theme=forest会覆盖大纲里的主题。 - 不写 → 默认取
engine/_theme_default.py的DEFAULT_THEME(现为ink-classic,最通用安全)。该值是 eink 引擎与 eink2pptx 的唯一源,两侧都从这里读,不许各自再写一遍字面量。
- 背景(WebGL 水墨双背景)与字体(衬线 / 无衬线)随主题自动着色;衬线切换用大纲顶部元数据
- 字体: serif|sans(--serif-zh是引擎内部 CSS 变量名,不是命令行参数)。 - 新增主题:改
engine/_eink_template.py顶部THEMES字典(唯一源)。 - 改默认主题:只改
engine/_theme_default.py的DEFAULT_THEME;eink2pptx侧会跟随(它按目录结构定位该文件读取,无本机绝对路径)。两侧若出现不同值,_tools/check_drift.py --strict会报 D1。
⚠️ 单一真相源(2026-08-19 立):本技能包(engine/ + templates/)是引擎与模板文档的唯一源。
各工作区不再保留render_outline.py/_eink_template.py/模板文档副本。
改引擎或模板:直接改本技能包,改完即生效(OneDrive 双机同步)。
各工作区只放:大纲 .md、images/配图、可选的_svg_overrides.json设计侧车(放各自生成目录,见「示意图注入(SVG 协议)」)、输出目录。
来源与许可
- 创作主体(© 2026 老陈 / 帮帮):整套 Python 渲染引擎(大纲 Markdown → 自包含 HTML 的渲染管线、契约系统、版式分发器)、版式体系 L01–L27(28 个)+ 通用元件、bat / tests / golden 自检等工具链,均为本技能从零创作(上游基础版为 HTML 模板 + mjs,不含任何 Python)。
- 风格来源(致谢):「电子墨水」视觉主题、WebGL 双背景(light/dark)、版式语法体系与字体栈(Playfair Display / Source Serif 4 / IBM Plex Mono / Noto Serif SC / Noto Sans SC)源自 guizang-ppt-skill(作者 op7418,GitHub),为本技能提供了视觉与模板地基;版式在其约 10 个布局基础上扩展为 28 个。迭代基础为 guizang-ppt-skill 的 WorkBuddy 市场版 v1.0.0(技能内部版本 1.0.1,约 2026-07-22 安装)。
- 许可:因包含 guizang-ppt-skill 的 AGPL-3.0 代码(原作 © 2026 op7418),本技能整包以 AGPL-3.0 发布并保留原作者版权;原创部分的著作权归属不受影响。完整文本见 LICENSE,第三方资源(Motion One MIT、Lucide ISC)见 THIRD_PARTY.md。
资源文件导览(本 skill 自带)
eink-deck/ ├── SKILL.md # 本文件:工作流 + 资源导览 + 指针(版本唯一源) ├── engine/ # 渲染引擎(工具本体,直接可跑) │ ├── render_outline.py # 解析层:大纲 md → lecture dict(命令行入口) │ ├── lint_outline.py # 大纲守护:渲染前体检(只报告不阻断,工作流 1b) │ ├── package_deck.py # 两步打包器:产物目录 → 单文件交付稿(工作流 4b) │ ├── prep_images.py # 配图预处理(渲染前可选):压体积/缩长边/统一 WebP(工作流 0) │ ├── _eink_template.py # 引擎:lecture dict → index.html(CSS/JS 内联固化) │ ├── syntax_registry.py # 语法注册表(机器层:lint/guard/生成器的输入) │ ├── check_syntax_anchors.py # 锚点自检:注册表 ↔ 引擎无漂移 │ ├── check_docs_consistency.py# 文档一致性 guard:文档 ↔ 注册表 │ ├── gen_syntax_docs.py # 生成器:注册表 → generated/ 人读文档 │ ├── golden_check.py # golden 门禁:示例大纲 + 16 讲 lint --strict │ ├── generated/ # 人读派生文档(机器生成 · 勿手改) │ │ ├── 语法速查.md # ★ 唯一人读语法文档(键字典 + 版式挂载) │ │ └── 示例大纲.md # ★ golden 基线(28 版式每版式一页可抄) │ └── eink_assets/ │ ├── motion.min.js # 入场动效依赖(classic build,本地,引擎自动引用,勿删) │ ├── motion.source.esm.js # 动效库 ESM 原版——只给 build_motion_classic.py 当源,勿手改 │ ├── build_motion_classic.py # 由 ESM 源生成经典版 motion.min.js(升级 Motion One 时重跑) │ ├── lucide.umd.min.js # 图标依赖(本地,引擎自动引用,勿删) │ ├── get_fonts.py # 离线字体源下载 │ └── README_fonts.md # 字体缓存/子集说明 ├── bat/ # 通用拖拽 bat(可拷到任意工作目录用,位置无关) ├── references/ │ ├── CHANGELOG.md # ★ 变更日志(版本唯一记录处,凡修改落日志) │ └── 二期重构论证.md # 引擎注册表驱动重构论证(阶段3 方案) ├── templates/ │ └── eink_模板模块规范.md # 视觉结构(一页长什么样;不含语法)
与上游技能的分工(2026-09-15):本技能只管「大纲 → 画面」,不管内容怎么想出来。 内容层(核心思想/切幕/记忆钉/讲稿取舍)与架构层(选型/骨架/锚点/三道闸)归
lecture-outline-design技能; 语法(字段怎么写/挂载)见engine/generated/语法速查.md;视觉见templates/eink_模板模块规范.md。 改语法或版式能力 → 改引擎 + 注册表 → 重跑生成器 → 落references/CHANGELOG.md。
工作流(用户怎么用)
0. 压缩配图(可选 · 渲染前 · 2026-09-09 起)
配图会被 base64 内嵌进交付稿,图越大单文件越臃肿。渲染前可先压一轮:
bash# 建议先干跑看收益(不写任何文件) python "<skill路径>/engine/prep_images.py" "images/某图.png" --dry-run # 确认后真跑:同目录同名、只换 .webp 扩展名,原文件保留 python "<skill路径>/engine/prep_images.py" "images" # 整个目录
人工拖拽:bat/02压缩配图-eink.bat(拖图片文件或文件夹都行)——先干跑、再要你确认才写,找不到带 Pillow 的 python 会直接报错而不是静默失败。
- 做三件事:长边缩到 ≤1920(只缩不放、不裁内容)、可选
--trim去纯色白边、统一转 WebP(照片类 q80;颜色 ≤256 的线条/地图类自动走无损,若更大则自动退回 q80); - 原文件一律保留,只新增
.webp;动图 GIF 跳过(转静图会丢动画); - 脚本绝不改大纲——报告末尾会列出需同步修改的
M12 文件:行,你改完再重渲; - ⚠️ 拖文件夹会处理里面所有图——干跑报告首行有「共 N 张」,确认前先看清楚数量。
1. 建大纲
- 复制
engine/generated/示例大纲.md为你的《XX讲大纲.md》(golden 骨架,28 版式逐式一页可抄); - 或照
engine/generated/示例大纲.md的骨架写:### Pxx · 简述起头,下面- 键: 值逐行; - 顶部元数据(必填 4 项):
- 讲次:/- 讲稿题目(M02):/- 系列名:/- 总讲次数:(纯元信息:引擎当前仅落元数据、未参与渲染;右下角页码分母恒为本讲实际页数)。 - 顶部元数据(可选):
- 主题:/- 字体:/- 讲次类型:(自由文本,仅作标注,引擎不解释)/- 块词表:。 - 配图放大纲目录的
images/子目录(如images/示意图.png)。 - 从讲稿开始(而不是从空白大纲开始):先用
lecture-outline-design技能由讲稿产出内容层大纲,本技能只负责渲染;接口见engine/generated/语法速查.md与上游 lecture 技能。
- 块词表:(可选 · M03 前段受控校验)
yaml- 块词表: 概述 / 方法 / 案例 / 小结
给 M03 前段(块名)做受控校验:写了之后,前段不在表内的页会打印 [块词表警告]——只警告、不阻断,渲染照常完成。不写则不校验(块名自由填写)。
反污染铁律:引擎不内置任何词表。某个系列若需固定板块名(例如五段式结构),一律写进该系列的
--profileJSON,由调用方注入,绝不写死进通用引擎。通用技能里的示例词表(概述/方法/案例/小结)只是占位示范,照抄前请换成你自己专题的分块名。
1b. 大纲体检(lint · 必跑 · 2026-09-09 起)
bashpython "<skill路径>/engine/lint_outline.py" "<大纲.md>"
- 为什么必跑:语法文档写得再全,作者(含 AI)照样会笔误——第9讲实测 7 处「强调: 裸短语」整行被静默丢弃。lint 是对大纲的守护(护栏脚本守的是技能本身)。
- AI 流程(硬纪律):生成大纲 → 跑 lint → 按提示自改 → 0 错误后才渲染。
- 人工手写:不指望主动跑——Windows 拖拽渲染 bat 已内置:渲染前自动 lint、只报告、不阻断。
- 检查项(全部来自本文件已文档化的坑 + 真实事故):页头不合式、页号重复、
强调:裸短语缺[ ](错误)、|||写在不支持分步的字段(警告)、M06/M08/M10 字号三控(警告)、同页多条M12 文件:(警告)、未知版式/模块码(错误)、M12 文件指向的图不存在(警告)、贴字斜杠疑似想分行(提示)、emoji(警告)、侧车 JSON 非法(警告)。 - 纯标准库,任何 python 直接跑;exit 恒为 0(报告不拦截,处理权在人/AI)。
2. 渲染
bashpython "<skill路径>/engine/render_outline.py" "<大纲.md>" "<输出目录>"
例:
bashpython engine/render_outline.py "第05讲大纲.md" "第05讲_输出" # 在 eink-deck 技能根目录下运行
- 引擎自动:解析大纲 → 拷贝
images/到输出 → 渲染index.html→ 打印校验(页数/浅深交替/emoji=0/figure 平衡/accent 数)。 - 输出目录不存在会自动创建。
--theme=xxx(覆盖主题)与--profile=<json 路径>(覆盖大纲顶部元数据,供派生技能注入固化项;profile 的键覆盖大纲同名元数据)可放在任意位置——引擎解析前先抽走--选项,不占位置参数。
2b. Windows 拖拽渲染 bat(可选)
技能自带两个通用 bat(bat/ 文件夹,推荐直接用):00拖拽渲染-eink.bat(拖大纲 md → 渲染,内置 lint 体检不阻断)与 01交付打包-eink.bat(拖产物文件夹 → 单文件交付稿)。两者位置无关:按共享目录 + 账户 junction 兜底定位引擎,拷到任何工作目录都能用;不绑任何系列 profile,主题/字体全按大纲自身元数据(系列 preset 见各系列自己的 bat,如武脉在 PPT制作 文件夹)。
另含 03自检-eink.bat:双击即跑 tests/test_smoke.py + scripts/golden_check.py 两套回归门禁(纯新增文件,不改 00/01/02 任何行为)。改完引擎后双击它即可确认没回归——比记命令行省事,且与你现有工作流一致。
⚠️ ARC-6·bat 定位逻辑(双机必读):00/01/02/03 先按 bat 自身所在目录的上一级定位 eink-deck 根(即
bat/的父目录),失败时的兜底路径是%USERPROFILE%\.workbuddy\skills\eink-deck。该路径必须是 junction(指向技能共享目录的 eink-deck),不能是独立副本——否则兜底会静默用到一份过期副本,渲染/自检结果都不可信。
不想用 bat、每次敲命令时,最小 bat 如下(把大纲 md 拖到它图标上即可渲染):
bat@echo off chcp 65001 >nul if "%~1"=="" ( echo drag your outline .md onto this bat & pause & exit /b ) set "OUT_DIR=%~dpn1_eink" python "<skill路径>\engine\render_outline.py" "%~1" "%OUT_DIR%" --profile="<profile.json>" echo Done: %OUT_DIR% pause explorer "%OUT_DIR%"
⚠️ bat 编码纪律(2026-09-02 实测,务必遵守):Windows bat 一律 纯 ASCII + CRLF +
chcp 65001,不写中文、不加 UTF-8 BOM。
- 机理:
chcp 65001切换代码页后 cmd 会重新定位文件指针。若文件含多字节中文(尤其 LF 换行时),行边界错位、把注释行切成"命令",stderr 冒出'xxx' is not recognized as an internal or external command——偶发,同一文件首跑报错、后几次正常,极难定位。加 BOM 则必定报'锘?' 不是内部或外部命令。- 实测:同一内容的中文 bat 连跑 5 次,第 1 次报
'file' / '两处:eink-deck' 不是内部或外部命令、第 2–3 次不报;改纯 ASCII + CRLF 后连跑 5 次 stderr 全空、中文(引擎侧输出)正常。- 做法:bat 里只写英文提示,中文信息交给 Python 引擎输出(UTF-8 代码页下正常显示)。不要用
set PYTHONIOENCODING=gbk替代——引擎输出若含 GBK 无法编码的字符会抛UnicodeEncodeError直接中断渲染。- ⚠️ 括号块内 echo 禁裸括号(2026-09-09 事故):cmd 对
if ... ( ... )是整块先解析再判断(条件真假都解析)。块内echo行若含未转义的(或),会把块提前闭合,报not was unexpected at this time、窗口一闪即退零输出。写法:改措辞避开,或转义^(^)。
3. 放映与调整
- 浏览器打开
<输出目录>/index.html:←/→ 翻页、空格前进、B 键静态、Home/End 首末页。 - 觉得"上一点/下一点/大一点"→ 改大纲的
Mxx 垂直/水平/字号数值,重新渲染(引擎无状态,改完重跑即可)。
4. 交互功能(放映时)
- 分步揭示:大纲
M10 正文: A ||| B→ 先 A、按右出 B;||| A ||| B(前导空)→ 初始空白逐段揭示。左键回退一段。M10 notes:同样支持|||(2026-09-09 起,见 §分步揭示)。 - pipeline 步进(L13):空格/→ 手动步进流程。
4b. 打包交付(两步方案 · 单文件可迁移 · 2026-09-09 起)
4b-0 · python 探测(AI / 命令行执行打包前必做)
直接 python package_deck.py 时,PATH 首发 python 常是 WorkBuddy 托管版(无 fonttools)→
字体静默降级为 CDN 链接、产物不自包含。AI 或命令行执行打包前必须先探测:
powershell# 探测当前 python 是否可用;失败则显式改用你自己装有 fontTools 的系统 python python -c "import fontTools, sys; print(sys.executable)" # 若上面的 python 缺 fontTools,把下面占位换成你本机装有 fontTools 的 python 绝对路径 # & "C:/your/python/path/python.exe" -c "import fontTools; print('fonttools OK')"
探测成功才用该 python 打包;人工拖拽可直接用 01交付打包-eink.bat(内置探测,首行打印
python [fonttools OK] : <路径>,看到此行才代表字体会内嵌)。找不到带 fonttools 的 python 时
先 pip install fonttools brotli 并跑 engine/eink_assets/get_fonts.py,再打包。
演示文稿多数不在自己电脑上放——交付稿必须拷一个文件就能用。两步方案:
| 步骤 | 谁执行 | 命令 / 操作 | 产出 |
|---|---|---|---|
| 1. 渲染 | AI 或人 | 工作流 2(照旧,完全不动) | <输出目录>/index.html + assets/(工作稿,可反复重渲) |
| 2. 打包 | AI 或人 | python "<skill路径>/engine/package_deck.py" "<输出目录>";人工拖拽:通用 bat/01交付打包-eink.bat(系列版如武脉在 PPT制作 文件夹) | <输出目录>/<去修饰词名>.html(目录名去 _eink,再去「大纲」)单文件 |
打包器做什么(不改 build()、不动渲染流程):
- 内联
motion.min.js+lucide.umd.min.js:两者都是 classic<script>,内联后交付稿拷到任何电脑双击都有动效与图标——这是「拷去别的电脑双击」成立的根基(2026-09-27:motion 已从 ESM 动态import()改为 classic script,file://下不再静默失效,内联与内联逻辑都更简单); localStorage守卫:存储被禁时用内存桩,B 键不静默失效(代价:静态/动态偏好不跨刷新持久);- 字体子集内嵌:按本 deck 实际用字切 Noto Serif/Sans SC + Playfair Display + Source Serif 4 的 woff2 子集 base64 内嵌、剥掉 Google Fonts
<link>——断网观感与在线一致。两个前提:① 能import fontTools的 python;② 本地字体缓存(~/.workbuddy/eink-font-cache/,先跑engine/eink_assets/get_fonts.py)。任一不满足 → 保留 CDN 链接并告警(降级不阻断)。⚠️ PATH 上第一个 python 常是 WorkBuddy 托管版(没装 fonttools),直接python package_deck.py会静默降级成"字体没内嵌"——交付 bat 会探测常见安装路径挑一个有 fonttools 的 python,并在首行打印python [fonttools OK] : <路径>;看到这行才算字体会内嵌; - 本地配图内联:产物
images/下的真实配图(M12 文件:引入)全部转成 data URI 内嵌,覆盖<img src="images/…">与 CSSurl(images/…)两种写法。渲染器只把图拷贝到images/并在 HTML 里写相对路径,不内联则交付稿一旦拷出产物目录,图片全开天窗(2026-09-09 老陈实测第2讲发现的 P0)。图缺失/路径越界 → 告警并保留原路径(不阻断,但交付稿会缺图)。base64 会使体积膨胀约 33%,单张 >1.5MB 会告警建议先压缩; - 锚点校验:产物结构不合预期(引擎升级改了装配)→ 大声报错退出,绝不静默出坏包。
动效故障怎么复现(2026-09-27 教训,别再走弯路):
- 用「页面加载后覆盖
window.requestAnimationFrame」模拟 rAF 停摆无效——animate()走 WAAPI(浏览器合成器驱动),不吃这个覆盖,新旧版本读数一致,会得出"缺陷不存在"的错误结论。 - 要复现动画冻结,必须用 CDP 真实冻结标签页:
Page.setWebLifecycleState {state:'frozen'}→ 等 →{state:'active'}。 - 兜底代码写 inline style 前必须先
el.getAnimations().forEach(a=>a.cancel()),否则 WAAPI 动画的 fill 状态继续压着opacity,inline 写了也是 0。
交付稿命名:目录名去 _eink 再去「大纲」修饰词(老陈 2026-09-09 定)——第2讲_大纲_eink → 第2讲.html;第9讲-李靖-大纲_eink → 第9讲-李靖.html。名字会删空则回退保留修饰词。交付稿名跟随大纲文件名,想要更有信息量就改大纲文件命名(如 第2讲-车战与妇好钺_大纲.md → 第2讲-车战与妇好钺.html),别手改产物。
工作稿 vs 交付稿:index.html 是可反复重渲的工作稿;<去修饰词名>.html 是一次性快照交付稿。两者都不要手改——改大纲、重渲、重新打包。工作稿目录含 assets/(有真实配图时还有 images/),交付稿是纯单文件(配图已 base64 内嵌)。
5. 各讲自备(不随 skill 分发)
images/配图(各专题自己的图)_svg_overrides.json(可选):侧车——需要不改大纲临时覆盖某页图时用。放该讲生成目录(输出目录),引擎渲染前自动加载。- 💡 常规不用它:图的常规载体是写进大纲该页
- svg: <svg …>(任意版式通用、页号随动、零外部文件,见「通用内联 SVG」)。 - ⚠️ 侧车为 JSON 纯字符串字典。引擎
json.load只读,绝不 import/exec 任何 Python,不存在"侧车被篡改即可任意代码执行(RCE)"的风险。
- 💡 常规不用它:图的常规载体是写进大纲该页
6. SVG 示意图:两种载体 ★
载体 A(常规):写进大纲页面 - svg: <svg …>(单行)——页号随动、零外部文件、单一真相源。完整语法见「通用内联 SVG」。
载体 B(可选):侧车 _svg_overrides.json——需要不改大纲做临时覆盖、或同一页反复调图时用;放该讲生成目录(输出目录),引擎渲染前自动加载。
json{ "第1讲/P11": "<svg viewBox=\"0 0 640 360\" xmlns=\"http://www.w3.org/2000/svg\"><text x=\"20\" y=\"40\" fill=\"currentColor\" font-family=\"Noto Serif SC\">示例</text></svg>", "第1讲/P20": "<svg viewBox=\"0 0 640 360\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"20\" y=\"20\" width=\"120\" height=\"40\" fill=\"none\" stroke=\"currentColor\"/></svg>" }
- ⚠️ 值 = 原始 SVG 字符串(直接以
<svg开头),不是 base64、不是包装。引擎链路:_load_svg_overrides()取到值后sanitize_svg(str(v))直接注入svg_inline,模板原样内嵌进<figure class="fig-svg">——全程不做 base64 解码,也没有任何 data URI 解析分支;按 base64 写,页面上会直接显示那串乱码文本。 - 键 = 「讲次/页号」复合键(多讲同目录也不串页);也兼容纯页号(如
"P11")。⚠️ 键名铁律:讲次与页号之间只有/、无空格(必须"第10讲/P21",不可"第 10 讲 / P21");且 SVG 字符串内所有双引号必须转义为\"(不可直接写"),否则整份 JSON 非法、引擎json.load失败、该讲全部侧车页一起消失(见「侧车 SVG」)。 - 加载顺序:生成目录(输出目录)→ 大纲所在目录 → 引擎目录(后两者为兼容部署的回退)。
- 🟥 侧车一律放该讲自己的生成目录——多讲同目录共用一份侧车会互相覆盖,后渲染的覆盖先渲染的,SVG 全丢。与页面
- svg:同页会直接报错,二选一。 - 🟥 侧车零执行:JSON 纯字符串字典,引擎
json.load只读,绝不 import/exec 任何 Python。
画 SVG 的通用铁律(两种载体都适用):
- 配色自适应:墨色文字/线条用
stroke="currentColor"/fill="currentColor"(继承页面深浅色,深色页自动转纸色);强调色用var(--accent)(随主题,不一定是红);严禁写死 #000/#fff。 - 字体
font-family="Noto Serif SC";画布一般为 640×360(16:9),加style="width:100%;height:auto";🟥 viewBox 按实际内容包围盒收紧——内容只占画布一部分时,把 viewBox 裁到内容实际范围(如内容仅 420×240 就写viewBox="0 0 420 240"),不留大段透明边浪费空间;示意类图配- svg_cap: 概念示意 · 非精确。 - 重渲即替换:改大纲重渲时图自动保留注入,不会丢。
安全与离线(2026-08-21 全面加固)
本引擎面向"渲染他人/网络来源大纲"的对外分发场景,已对所有外部输入做统一安全闸,渲染产物不执行任何外部代码:
- 侧车零执行:设计侧车是 JSON 纯字符串字典(
_svg_overrides.json),引擎json.load只读,绝不 import/exec 任何 Python。 - 数值字段白名单:
垂直/水平/maxh/ratio等只能填数字+(vh|vw|px|%)或NxM/auto,非法值(含引号、脚本片段)直接忽略,杜绝style/class属性逃逸引发的 XSS。 - SVG 注入前清洗:
- svg:内联、侧车svg_inline、items的icSVG 片段,注入前经sanitize_svg()剥离<script>/<foreignObject>/<iframe>/<embed>/<link>/<meta>、!ENTITY与全部on*=事件属性,封死javascript:/data:(非图片)协议。 - 图片路径穿越防护:
M12 文件:拒绝绝对路径与..穿越,解析后必须落在大纲目录内,否则退回占位——借配图读出任意本地文件不可行。 - 脚本依赖全本地化:lucide / motion 随引擎内置(
eink_assets/→<输出>/assets/),JS 侧不再引用任何外部 CDN(unpkg / jsdelivr 已移除),消除供应链投 JS 风险;本地加载失败仅优雅降级为静态,不发任何外部 JS 请求。字体不在此列(见下条)。 - 唯一网络项 = 字体:Google Fonts
<link>仍保留(Playfair Display/Source Serif 4/IBM Plex Mono/Noto Serif SC/Noto Sans SC共 5 族,字重 300–900 由 CSS 决定,非作者自选),离线时自动回落系统衬线(source-han-serif-sc/serif/Microsoft YaHei),不影响内容与安全,仅字形略变。⚠️ 这不是"小请求":字体 CSS 约 1.24 MB(1193 个@font-face段)会整份下载——Google 的按需加载只对字体文件生效、对 CSS 不生效;实测一篇讲稿真正触发的切片仅约 220 KB。彻底离线现已支持:大纲写字体源: 本地(或 CLI--offline-fonts/ profile字体源: 本地),引擎在构建期把本 deck 实际用字从本地 Noto SC 源切出子集、base64 内嵌进 index.html,产物自包含、断网也不豆腐块(详见下「字体离线开关」)。未开启离线时仍是上述 CDN 行为。 - 自己写侧车/大纲也建议遵守:即便只渲染自己的大纲,按 JSON 侧车 + 白名单取值写,能避免"顺手粘贴一段带
<script>的 SVG"之类误伤。
字体离线开关(子集内嵌)
默认字体走 Google Fonts CDN(见上「唯一网络项 = 字体」)。开启离线开关后,引擎在构建期把本 deck 实际用到的中文字符从本地 Noto SC 源切出子集、base64 内嵌进 index.html,产物自包含、断网也不豆腐块。
触发方式(任选其一,优先级:CLI > 大纲/profiler)
| 方式 | 写法 | 说明 |
|---|---|---|
| 大纲元数据(规范键) | 字体源: 本地 | 推荐;与 字体: serif/sans 基调互不干扰 |
| 大纲元数据(兼容快捷) | 字体: 本地 | 同时触发离线且基调回落默认衬线 |
| 命令行 | --offline-fonts(或 --offline) | 优先级最高,覆盖大纲 |
| 派生 profile | 字体源: 本地 | 武脉等系列可一次性固化默认离线 |
字体: serif/sans仍表示衬线/无衬线基调(旧语义不变)。离线时若想另指定基调,用字体基调: sans等独立键,不与字体: 本地冲突。
一次性准备(构建机只需一次)
- 取本地字体源(完整可变 TTF,不进产物,只用于切子集):
bash
python engine/eink_assets/get_fonts.py # 默认缓存 ~/.workbuddy/eink-font-cache/ EINK_FONT_DIR=/your/cache python engine/eink_assets/get_fonts.py # 或自定义目录 - 构建期依赖
fonttools(含 brotli 才能读写 woff2):pip install fonttools brotli。 - 详细见
engine/eink_assets/README_fonts.md。
优雅降级(有就生效、无就安全退网):若 fonttools 未装、或本地源 woff2/ttf 缺失,引擎会打印明确告警并回退到 Google Fonts CDN 链接(产物仍需联网,但不会报错、不会豆腐块)。离线开关不会因缺料而崩。
产物行为:离线成功时 <head> 去掉 Google Fonts <link>,改内联 @font-face(base64 子集);中文衬线/无衬线保证离线可用;西文字体(Playfair / Source Serif / IBM Plex Mono)仍走系统回退(Georgia 等),不影响中文离线。子集大小随去重字数线性增长——以第9讲(约 1165 去重字)为例,内嵌约 1.16 MB(整库 25 MB 仅取 3.8%),与 CDN 版体积相当,但产物自包含。
重要约束:子集是「冻结」的:子集只在构建那一刻按当时字集切好并内嵌。正常改稿必须回到大纲重渲(这点与 CDN 模式一样,两者都要重渲);但离线产物是「密封成品」——不要手改产物 index.html 往里加字(新字不在子集里 → 豆腐块 □),要加字回到大纲重渲(重渲会自动重新切子集)。推荐工作流:撰写期用 CDN(全量字体、零摩擦),交付期加 字体源: 本地 重渲一次冻结子集。
语法唯一源(指针)
语法事实只此一处:引擎代码(行为)→
engine/syntax_registry.py(机器注册表)→engine/generated/语法速查.md(人读)。
- 字段怎么写 / 取值 / 挂载:见
engine/generated/语法速查.md(机器生成 · 勿手改)- 完整可抄样例:见
engine/generated/示例大纲.md(golden 基线,28 版式每版式一页)- 一页长什么样 / 视觉结构:见
templates/eink_模板模块规范.md- 架构与方法(选型 / 骨架 / 锚点 / 三道闸):见上游
lecture-outline-design技能 文档「字段用法 / 组件参考」段只许含注册表 ACTIVE 键;DEAD 键只存在于注册表(lint 锚)与 CHANGELOG。教 DEAD 键 = 漂移,删除。
示意图注入(SVG 协议)
写大纲时直接内联。协议只此一处定义,其他文档(lecture 技能 / 示例大纲)引用本处,不重复定义。
- 🟥 常规唯一通道 A 内联:大纲该页
- svg: <svg …>(任意版式通用,L12 亦通);页号随动、零外部文件。 - 🟨 通道 B 侧车(不改大纲的临时覆盖):
_svg_overrides.json放该讲自己的输出目录,键"讲次/页号";与页面- svg:同页会报错。 - 🟥 一页只允许一个图源:侧车 /
svg:任意两个 → 引擎直接报错退出(SystemExit);L12bitems:与svg:/侧车同页 → 引擎不报错但静默丢弃 svg,仅 lint 报错误级拦截。 - 🟥
- svg:值必须压成单行——引擎逐行解析,只取第一行;多行写法会让</svg>及全部内容被静默丢弃。 - 🟥 值 = 原始 SVG 字符串(以
<svg …>开头),不是 base64、不是包装;md 里双引号照常写",不转义。 - 🟨 页面内不得有
- 设计:占位行;需图注加- svg_cap:(所有版式都渲染)。 - 配色:文字线条
currentColor(继承深浅色),强调var(--accent),严禁写死 #000/#fff;示意类配svg_cap: 概念示意 · 非精确。画布上限 640×360(16:9);🟥 viewBox 按内容包围盒收紧(不留大段透明边)。
维护者红线:领域污染审计(改引擎/改模板/改示例前后必做)
本技能是通用引擎。任何"只在某一个具体专题下成立"的约定混进来,都是污染。
⚠️ 关键认知:靠关键词词表检索做审计必然漏——领域词列不完("府兵制""关陇集团""投鞭断流"这类词,词表里没有也能污染)。必须按下面三层做语义级通读,而不是 grep 几个专名。
三层污染模型
| 层 | 表现 | 危害 | 典型实例(已清理) |
|---|---|---|---|
| ① 行为层 | 领域约定硬编码进引擎、实际改变输出 | 最高:通用场景被强制套用错误规则 | 内置 M03 固定五词表;按「讲次类型=人物传记」强制校验 |
| ② 示例层 | 文档/示例里把具体专题内容当范本 | 高:AI 写新讲时照抄即中招 | 示例大纲 8 页沿用某讲真实素材;语法速查的 M06/M08/M10 示例句 |
| ③ 措辞层 | 把某主题下的视觉结果当成通用事实 | 中:误导维护者 | 注释写"朱砂红 accent"(accent 随主题变,非恒定红) |
审计清单(逐项过,不要挑着做)
- 引擎:
grep引擎 py 内是否出现任何词表 / 分类枚举 / 内容级默认值。有 → 外置为可选字段(如- 块词表:),引擎不内置。 - 引擎 docstring / 注释:是否出现具体专题名、人名、事件名、专属标语(如"脉续千年")、用户称呼。有 → 中性化。
- 自检样例(
__main__):整段是否来自某个真实讲次。是 → 重写为中性;样例内 SVG 是否写死某主题的 hex(违反"配色自适应铁律")。 - 示例大纲:通读每一页的内容文本(不是 grep),问一句"这一页在讲谁的什么事?"——若答案是某个具体专题,就是污染。
- 语法速查 / 模板规范:所有「示例」列、代码块里的示例值。
- 措辞:
朱砂红→accent 强调色;第X讲/第16讲→ 泛化;老陈→用户。 - changelog:引用「第N讲」这类验证对象编号可保留(有可追溯价值);但具体历史人名/事件实例必须中性化。
- 收尾:删
__pycache__(陈旧二进制会污染 grep 结果)→py_compile→ 用示例大纲端到端渲染 → grep 产物确认领域词为 0。
判定口诀
"这句话,换到另一个完全不同的专题上还成立吗?"
不成立 → 污染。要么外置成配置(写进该系列的--profile),要么换成中性表述。
引擎侧的唯一正确形态:引擎只认字段名,不认任何具体内容值。
文档纪律(2026-09-08 立,2026-09-15 精简)
- 新增语法条目默认进「语法速查」(
engine/generated/语法速查.md,从注册表生成),按正确写法陈述("要这样写"),不写"别这样错"。 - 「字段用法 / 组件参考」段只许含注册表 ACTIVE 键;DEAD 键只存在于注册表(lint 锚)与 CHANGELOG。教 DEAD 键 = 漂移,默认删除而非标注。
- 已解决的坑、已修复的问题、已过时的表述一律删除,不留档(版本记录只在
references/CHANGELOG.md)。
配套:文档内交叉引用一律用章节名(「见『语法唯一源』」),不用编号。编号会随重构漂移,名称不会。
引擎维护纪律(改 engine/*.py 前必读 · 2026-09-04 立)
前五轮收敛踩出来的坑,每条都实际发生过。按序做,不要跳步。
1. 先写黄金单测锁行为,再动手改
写测试 → 跑出红(证明测试真的能抓住问题)→ 改引擎 → 跑绿 → 端到端回归。
禁止"改完再说"或"凭印象认为没影响"。护栏脚本放 .workbuddy/tmp/,可重复跑。
2. 查 CSS 必须读常量值,禁止行首锚定 + 位置切片(★ 已踩两次)
_CSS 是单行超长字符串,_CSS_EXTRA 是三引号块。两个错误叠加会得出完全错误的结论:
- ❌
grep "^\.chrome\{"—— 行首锚定对单行内的规则必然零命中 → 误判"规则不存在/评审误报"; - ❌
src[src.index("_CSS ="):src.index("_CSS_EXTRA")]——index可能命中 docstring 里提到的同名串,
位置早于真实定义 → 切片反向为空。
✅ 正确做法:import 模块后取 T._CSS + T._CSS_EXTRA(已求值常量),或至少去掉锚定、不用位置切片。
3. _CSS 是单引号字符串:源码里的换行必须是字面 \n 两字符
往 CSS 里插注释时若用 Python 真实换行 → SyntaxError: unterminated string literal。
修法:字符扫描精确界定字面量区间(遇 \\ 跳过下一字符、遇 ' 结束),区间内真实换行统一改回字面 \n。
4. 合并 CSS 重复规则必须保留"后者胜"的层叠语义
同一选择器在两处定义时,后出现的属性覆盖先出现的。合并成一条后若取值弄反,会静默改掉全系列版式。
合并前后必须逐属性比对「层叠终值」是否一致(有 verify_css_cascade.py 可参考)。
注意:有些"重复"其实是服务于不同 DOM 结构的选择器,不能删。
例:figure.fig-svg > svg(svg 直挂 figure)与.fig-svg svg(figure>div>svg孙元素)都要保留,
只应统一其上限值,让页面通过内联M12 maxh覆盖。
5. 启发式阈值必须按实测分布校准,不能拍脑袋
新增告警类机制后,"零告警"不等于机制有效——要统计真实分布验证阈值落在合理位置。
实例:正文溢出阈值初版定 320 字,全系列 199 个正文页(中位 110 / 最大 242)跑出 0 条,等于没加;
按分布改 200 后触发 2 页(正是最长的两页)才真正可用。
6. 改动后做「分区比对」,证明正文零改动
整文件 diff 会因 CSS/强调包裹变化而全红,看不出问题。应按 <style> 切分:
CSS 区允许变、正文区(去掉 accent 包裹后)必须逐字节一致。同时把残留的疑似硬编码色
(如 #2a1e13)逐条查上下文,确认是 :root 主题兜底(合法)还是真残留。
6b. 无 git 时的「新旧引擎 A/B 副本法」(2026-09-10 立)
技能目录不纳入版本控制,改完引擎没有"改前版本"可比。要证明"零回归"须自建对照组:
- 改完后把
engine/render_outline.py与engine/_eink_template.py各复制一份到临时目录,精确还原改动点(把新增逻辑改回旧行为)→ 得到"旧引擎"; - 复制出的
render_outline.py要把from _eink_template import改成指向副本名,否则仍导入现行模板; - 用全量真实大纲跑两边(
新版引擎vs旧引擎),输出到两个目录; - 比对必须带侧车——把各讲的
_svg_overrides.json复制进临时输出目录,否则fig-svg数全为 0,比对假绿; - 判据:
<style>/<script>剥掉后逐字节比。差异必须只在被改动的功能上(如"仅新增图注"= 去掉div.svg-cap后两边完全一致),其余必须字节级一致。
判据要用精确 DOM 串,别搜类名:.svg-cap / .fig-svg 这些词在 CSS 规则里也出现,只 count("svg-cap") 会得到每页恒定的假阳性。要 re.findall(r'<div class="svg-cap"', body)。
测试标记要完全区分:FIGSVG图 含子串 SVG图,会造成假阳性——用 AAA_ / BBB_ / CCC_ 这类互不包含的标记。
7. 写测试大纲时的两个硬约束(不然页数为 0 / 字段不生效)
- 分页标记必须是
## P01 · 标题(正则^#{2,4}\s+(P\d+[A-Za-z]?)),写## 页1…解析出 0 页; svg/svg_cap/meta-row是无 M 前缀的版式字段,写成- M12 svg:不生效。
版本
版本唯一源:本文件 frontmatter
version(其他文件不再维护版本号)。 全部改动记录:references/CHANGELOG.md(凡修改必落日志;已解决的坑、已过时的表述一律删除,不留档)。
维护与贡献
本技能以独立仓库发布。开发约定:
- 编码:统一
UTF-8编码与LF换行;bat文件保持纯ASCII与CRLF(其首部已声明)。 - 运行:需要 Python 3.8+(推荐 3.13.x 并装
fontTools,以自包含字体)。bat优先用系统PATH中的python,缺失时回退;引擎根通过%~dp0..相对定位,并以%USERPROFILE%\.workbuddy\skills\eink-deck兜底。 - 验证:
python tests/test_smoke.py(覆盖 28 版式渲染断言);python engine/golden_check.py --skip-render做 lint 门禁;设EINK_OUTLINES_DIR指向实纲目录可跑完整 golden 自检。 - 版本号:
SKILL.mdfrontmatter 的version为唯一真相源(本仓库不另设版本号副本)。兼容性不变时升次版本,破坏性改动升主版本。 - 欢迎通过 Issue / PR 反馈与贡献。


