forked from arechen/EapilSkillMarket
Sync third-party and MCP marketplace plugins
Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed.
This commit is contained in:
1 parent
692889f8fd
commit
221f0178b1
71 files changed
+2144
-401
No files matched your search
@@ -42,8 +42,17 @@
|
||||
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "4aad0584d92131626b16d4ff4d77f0455385013c",
|
||||
"syncedAt": "2026-09-06T15:59:59Z"
|
||||
"commit": "7f69fed6a2717900085f1bc3b263721f8ba025e2",
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
},
|
||||
{
|
||||
"id": "caveman",
|
||||
"pluginName": "caveman",
|
||||
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
||||
"ref": "main",
|
||||
"adapter": "codex-plugin",
|
||||
"commit": "15581d14007fd01fb3f132016741962f34936ca2",
|
||||
"syncedAt": "2026-09-10T23:13:42Z"
|
||||
},
|
||||
{
|
||||
"id": "taste-skill",
|
||||
@@ -96,8 +105,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "64b65839c7f8096a534c872c03d688a2b2491c8f",
|
||||
"syncedAt": "2026-09-09T16:00:00Z"
|
||||
"commit": "09ad58f0d58decc9d30799ca83374ff2604ef16b",
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
},
|
||||
{
|
||||
"id": "grill-me",
|
||||
@@ -121,10 +130,10 @@
|
||||
"id": "etunel-role-collaboration",
|
||||
"pluginName": "etunel-role-collaboration",
|
||||
"repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git",
|
||||
"ref": "v1.0.2",
|
||||
"ref": "v1.0.3",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "a08cfd6a3b4bfa38a3b3ac3a5b56bbd56502a514",
|
||||
"syncedAt": "2026-09-09T04:01:45Z"
|
||||
"commit": "20c0de9b3829534685d1f931dc0b574c988eec6b",
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
},
|
||||
{
|
||||
"id": "next-skills",
|
||||
@@ -132,8 +141,8 @@
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "e0966df11438b1f82e06d6f650a2a044b84457c3",
|
||||
"syncedAt": "2026-09-09T16:00:00Z"
|
||||
"commit": "ccef3db535aa163a883f6ac000bdceb16b13e5e5",
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
},
|
||||
{
|
||||
"id": "shuorenhua",
|
||||
@@ -143,15 +152,6 @@
|
||||
"adapter": "claude-skill",
|
||||
"commit": "d2d0ce27da295581c3cf87a30ab65deb7d0ddfb8",
|
||||
"syncedAt": "2026-09-03T02:45:43Z"
|
||||
},
|
||||
{
|
||||
"id": "caveman",
|
||||
"pluginName": "caveman",
|
||||
"repo": "https://github.com/JuliusBrussee/caveman.git",
|
||||
"ref": "main",
|
||||
"adapter": "codex-plugin",
|
||||
"commit": "15581d14007fd01fb3f132016741962f34936ca2",
|
||||
"syncedAt": "2026-09-10T23:13:42Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "etunel-role-collaboration",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "Etunel 多 Agent 项目的角色识别、Hub 中介、依赖任务波次、成果交接和消息流程协作规范。",
|
||||
"author": {
|
||||
"name": "chenmingxuan",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"sourceId": "etunel-role-collaboration",
|
||||
"repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git",
|
||||
"ref": "v1.0.2",
|
||||
"commit": "a08cfd6a3b4bfa38a3b3ac3a5b56bbd56502a514",
|
||||
"ref": "v1.0.3",
|
||||
"commit": "20c0de9b3829534685d1f931dc0b574c988eec6b",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "etunel-role-collaboration",
|
||||
"syncedAt": "2026-09-09T04:01:45Z"
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
}
|
||||
+1
-1
@@ -59,7 +59,7 @@ Etunel 负责当前角色和会话绑定。不要向负责人询问或复述 rol
|
||||
- 缺信息、资料冲突、查询无新线索、旧任务收尾、错投、阻塞、超时升级、会议决定、返工、变更、流程跳转或模拟:读 [异常与协调](references/exceptions-and-coordination.md)。
|
||||
- 编写给负责人、角色或群组的任务、提问、结果和状态消息:读 [人类可读沟通](references/human-readable-communication.md)。
|
||||
- 创建项目资料目录、保存成员清单、归档成果、更新进度或阶段记录:读 [项目文件与进度留档](references/project-files-and-progress.md)。
|
||||
- 仅当当前会话是 Hub 且需钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),实际调用再读其中的脚本用法。
|
||||
- 仅当当前会话是 Hub 且需初始化个人 DWS 通知、钉钉进度汇报或负责人辅助提醒:读 [钉钉项目进度汇报](references/dingtalk-progress-reporting.md),实际调用再读其中的脚本用法。
|
||||
- 测试角色仅在任务类型匹配时,按 [测试职责](references/roles/testing.md) 中的二级链接读取更深细则。
|
||||
|
||||
不要为“全面了解”一次加载全部引用或全部角色文件。Etunel Hook 源文件独立配置,不属于本 Skill 的渐进式发现树。
|
||||
|
||||
+8
-6
@@ -5,12 +5,12 @@
|
||||
## 权限与封装入口
|
||||
|
||||
- 只有 Hub/主 Agent 可以发送;成员 Agent 只把成果、状态或阻塞交给 Hub。
|
||||
- 项目配置为启用时视为持续发送授权,不需每条消息再次询问。
|
||||
- 只用项目封装:`dingtalk-progress` 处理五类项目事件,`dingtalk-bot-message` 处理自定义中文摘要或负责人提醒;二者共用 `scripts/dingtalk_notify.py`。Windows、macOS/Linux 的命令及文件正文、人员提醒用法见 [通知脚本用法](dingtalk-script-usage.md),实际调用前按需读取。
|
||||
- 项目首次初始化时,由 Hub 查询可用个人 DWS 账号和群,以姓名、组织和群名向负责人确认自动通知范围,再调用 `init` 创建配置;不让人填写编号或密钥。确认并启用后,范围内通知不需每条再次询问,变更账号或群须重新确认。
|
||||
- 只用项目封装:`dingtalk-progress` 处理五类项目事件,`dingtalk-message` 处理自定义中文摘要或负责人提醒;二者共用 `scripts/dingtalk_notify.py`,统一使用确认的个人 DWS 账号。初始化、跨平台入口及 @ 用法见 [通知脚本用法](dingtalk-script-usage.md)。
|
||||
|
||||
- Agent 不得直接调用底层 OpenAPI、`dws api` 或其他消息入口绕过封装脚本。
|
||||
- Agent 不自行打开、展示或修改真实配置,不请求负责人在会话中提供密钥和内部标识;由封装在本地读取 `config.env` 中的 `DINGTALK_PROGRESS_CLIENT_SECRET` 并完成认证,不再使用环境变量或钥匙串取密钥。
|
||||
- Skill 与消息正文不得硬编码项目 Client ID、robotCode、openConversationId、Client Secret 或群名;项目差异只由 `.dingtalk/config.env` 和封装脚本处理。
|
||||
- 配置只保存开关、固定账号、项目群和项目名称;Hub 可在已确认范围内创建或更新,登录凭据由 DWS 管理,不索取、读取或输出 Token、密码与应用密钥。旧机器人配置须重新初始化,不静默切换发送身份。
|
||||
- 不在 Skill 或群消息里硬编码账号、群或人员标识;标识从同一账号的真实工具结果取得,仅用于本地配置和工具参数。不因默认账号切换而更换发送人,不退回机器人或 Webhook。
|
||||
|
||||
## 汇报单位与顺序
|
||||
|
||||
@@ -58,6 +58,8 @@ Hub 必须先确认进展真实有效,并完成成果文件、成果索引和
|
||||
|
||||
使用中文 Markdown 自然排版,可以使用标题、短段落和列表,不要求固定模板,也不要把全部内容挤成一行。像项目负责人向团队说明进展,不像日志转储。
|
||||
|
||||
保留个人消息的 AI 标识和自动汇报说明,让群成员知道这是 Hub 自动通知,不是负责人本人新增批准、验收或承诺。
|
||||
|
||||
根据事件选择必要内容:项目名称、当前阶段、已验证进展、关键成果、下一步和责任角色;阻塞时再说明问题、影响以及需要谁采取什么动作。通常保持一屏内可读。
|
||||
|
||||
不在正文中写运行时 ID、英文成果名、机器状态码或内部字段。只写已验证结论,不得包含密钥、Token、个人数据、客户敏感信息、大段日志、完整内部对话或未经验证的根因推断。
|
||||
@@ -68,8 +70,8 @@ Hub 必须先确认进展真实有效,并完成成果文件、成果索引和
|
||||
|
||||
1. Hub 判断事件并形成中文 Markdown 摘要。
|
||||
2. 调用封装脚本一次;脚本自行处理配置检查、发送和有限重试。
|
||||
3. 只有真实发送响应包含非空 `processQueryKey`,才表示钉钉服务端已经接受;不证明群成员已读。
|
||||
4. 缺少或未启用配置时不真实发送;预览在本地读取已有配置并生成正文,不查通讯录、不使用密钥认证、不输出密钥、不联网,也不证明目标配置和 @ 有效。
|
||||
3. 只有真实个人发送响应成功且含非空 `openTaskId`,才表示请求已经接受;不证明已投递、已读或接收端 @ 提醒成功。必要时按脚本用法查询投递状态,不把任务回执当消息编号。
|
||||
4. 缺少或未启用配置时不真实发送;显式 `--dry-run` 仅在本地生成正文,不查通讯录、不联网,也不证明目标配置和 @ 有效。配置仍为旧格式时停止并请求重新初始化。
|
||||
5. 只有明确的域名解析失败才自动重试,总计最多 3 次,间隔 10 秒;首次收到有效回执立即停止。认证、权限或未知错误不自动重试;超时、连接中断或缺少回执可能已发出,不盲目重发。单次命令最长等待 60 秒。
|
||||
6. 有效回执优先于命令退出码,不因退出异常重复发送。发送停止或结果未知时,Etunel 主任务继续;Hub 在本地结果中向负责人说明一次,不递归发送新的阻塞或失败通知,不自动补发历史事件。
|
||||
|
||||
|
||||
+36
-45
@@ -1,61 +1,52 @@
|
||||
# Hub 通知脚本用法
|
||||
# Hub 个人 DWS 通知用法
|
||||
|
||||
仅在 Hub 已按 [通知规则](dingtalk-progress-reporting.md) 判断需要通知、准备调用脚本时读取。脚本须由部署负责人放在目标 Hub 项目的 `scripts/`;安装 Skill 本身不会部署脚本、配置或凭据。找不到入口时说明未部署并继续主任务,不猜绝对路径、不临时绕过封装。
|
||||
仅在 Hub 初始化通知或按 [通知规则](dingtalk-progress-reporting.md) 准备发送时读取。脚本放在实际 Hub 项目的 `scripts/`,配置位于同项目 `.dingtalk/config.env`;安装 Skill 不会自动部署脚本。缺少入口时说明并继续主任务,不猜路径或绕过封装。
|
||||
|
||||
## 选一个入口,不重复调用
|
||||
## 项目首次初始化
|
||||
|
||||
共用实现为 `scripts/dingtalk_notify.py`,依赖 Python 3.9+;真实发送还需原生 `dws`。相对命令从目标项目根目录执行。
|
||||
1. 用 `dws profile list --format json` 查询账号,向负责人显示姓名与组织;未登录请负责人完成 DWS 登录,不索取凭据。
|
||||
2. 在选定账号下,用 `dws chat +chat-search --query "负责人提供的群名称" --page-all --profile "实际账号标识" --format json` 查群。账号或群有歧义、结果不完整时先澄清,不猜编号、不选第一项。
|
||||
3. 一次确认发送账号、项目群及是否允许自动进度和必要 @ 提醒。人只确认名字与用途,不负责找编号。
|
||||
4. Hub 将真实查询结果传入 `python scripts/dingtalk_notify.py init --profile "实际账号标识" --group "实际群标识" --project "项目名称" --yes`。这是参数结构示例;值必须替换为同一账号的实际结果。脚本创建仅含开关、账号、群和项目名的配置,不存机器人密钥或登录凭据,不发测试消息。
|
||||
|
||||
所有系统由封装统一从目标项目 `.dingtalk/config.env` 的 `DINGTALK_PROGRESS_CLIENT_SECRET` 读取机器人密钥,不回退到环境变量或钥匙串。配置由部署负责人本地维护;Hub 不打开或展示真实配置、不在会话索取密钥。普通发送不需要 DWS 用户登录,按姓名提醒才额外需要同组织的已登录账号配置。
|
||||
已有有效配置直接沿用;变更发送账号或群须重新确认,初始化时加 `--replace`。旧机器人配置必须重新初始化,不能静默转换发送身份。账号、群选择与查询由 Hub 完成;`init` 校验账号登录,不替代负责人对目标群的确认。暂停通知可由 Hub 按负责人要求将 `DINGTALK_PROGRESS_ENABLED` 改为 `0`。配置和标识只用于本地工具,不粘贴到群或普通回复,不提交真实配置。
|
||||
|
||||
| 环境 | 项目事件 | 中文摘要/负责人提醒 |
|
||||
| --- | --- | --- |
|
||||
| 通用 Python | `python scripts/dingtalk_notify.py progress ...` | `python scripts/dingtalk_notify.py message ...` |
|
||||
| Windows PowerShell 7.3+ | `./scripts/dingtalk-progress.ps1 ...` | `./scripts/dingtalk-bot-message.ps1 ...` |
|
||||
| macOS/Linux | `sh scripts/dingtalk-progress ...` | `sh scripts/dingtalk-bot-message ...` |
|
||||
初始化时同时确认目标项目的 Git 忽略规则包含 `.dingtalk/config.env`,不把本机通知配置提交到仓库。
|
||||
|
||||
Python 在 macOS/Linux 通常叫 `python3`;Windows 也可用 `py -3`。这是同一实现的不同入口,不是失败后依次尝试的备选发送渠道。原有 `./scripts/dingtalk-progress` 调用仍可使用,前提是该文件有执行权限。
|
||||
## 选择本机入口
|
||||
|
||||
## 项目事件通知
|
||||
共用实现依赖 Python 3.9+ 和已登录的原生 DWS。相对命令从目标项目根目录执行。
|
||||
|
||||
| 环境 | 初始化/检查 | 项目事件 | 摘要/提醒 |
|
||||
| --- | --- | --- | --- |
|
||||
| 通用 Python | `python scripts/dingtalk_notify.py init ...`/`check ...` | 同入口 `progress ...` | 同入口 `message ...` |
|
||||
| Windows PowerShell 7.3+ | `./scripts/dingtalk-python.ps1 init ...`/`check ...` | `./scripts/dingtalk-progress.ps1 ...` | `./scripts/dingtalk-message.ps1 ...` |
|
||||
| macOS/Linux | 通常用 `python3` 执行共用入口 | `sh scripts/dingtalk-progress ...` | `sh scripts/dingtalk-message ...` |
|
||||
|
||||
Windows 优先用 `.ps1`,须同时部署共用启动器 `dingtalk-python.ps1`,它选择独立 Python,不使用可能隔离 DWS 登录的商店版;必要时用 `DINGTALK_PYTHON` 指定解释器。各系统均通过 PATH 查找原生 DWS,不再设置机器人相关参数。这些入口是同一实现,不能重复调用。
|
||||
|
||||
## 日常发送
|
||||
|
||||
```text
|
||||
python scripts/dingtalk_notify.py progress milestone "**已完成**:本阶段成果已确认并归档。下一步安排联调。"
|
||||
python scripts/dingtalk_notify.py progress milestone "**已完成**:阶段成果已确认并归档。下一步安排联调。"
|
||||
python scripts/dingtalk_notify.py progress blocked "缺少接口说明,请补充后回自己的角色会话同步。" --at "完整姓名"
|
||||
python scripts/dingtalk_notify.py message --title "资料补充提醒" --text "请补齐已约定资料,并回自己的角色会话确认。" --at "完整姓名"
|
||||
```
|
||||
|
||||
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。脚本自动加项目名、中文状态和本机带时区时间;不自动判断项目阶段或完成状态。
|
||||
事件仍只有 `start`、`milestone`、`blocked`、`complete`、`failed`。脚本增加项目名、中文标题、带时区时间和自动汇报说明,不替 Hub 判断阶段。保留 DWS 的 AI 标识,不能冒充负责人本人批准或承诺。
|
||||
|
||||
多行 Markdown 可直接作为一个参数传入。较长或含引号的摘要优先保存为 UTF-8 正文文件,再用 `--file`;不要把大段成果或日志当通知:
|
||||
- 多行中文 Markdown 作为一个参数传入;较长摘要可先保存成 UTF-8 文件,再用 `--file "实际正文文件路径"` 替代摘要或 `--text`。文件须实际存在,路径相对工作目录;这是正文,不是上传附件,不发送大段成果或日志。
|
||||
- `progress` 和 `message` 均支持 `--at`,多个姓名可逗号分隔或重复传入。负责人姓名来自 Etunel 已绑定的 `member_alias`,不把角色名称当姓名;人员查询和发送固定使用初始化账号,不跟随默认账号切换。
|
||||
- 脚本只接受唯一姓名匹配,自动构造个人消息所需的 `openDingTalkId` 和提及标记。查不到、重名或权限不足时停止,不让负责人手填编号,不删掉 @ 降级发送。
|
||||
- 默认且仅发配置的项目群。同一事项合成一条,事件通知可直接 @ 人,不额外重复提醒。提醒讨论结果回各自角色会话,不在群里另派任务或批准。
|
||||
|
||||
```text
|
||||
python scripts/dingtalk_notify.py progress milestone --file "项目资料/00-项目管理/通知摘要.md"
|
||||
```
|
||||
## 检查与结果
|
||||
|
||||
这里的路径是调用示例,须先在实际项目中生成相应摘要,不能假定文件存在。文件路径相对当前工作目录解析;`--file` 是读取消息正文,不是上传附件。进度摘要和 `--file` 二选一。
|
||||
排障可用 `python scripts/dingtalk_notify.py check --at "完整姓名"`,省略 `--at` 只查登录;它联网但不发送,不是每轮必做。两个发送入口加 `--dry-run` 可完全离线预览,不查人、不验证目标或 @ 效果。
|
||||
|
||||
## 自定义摘要与负责人提醒
|
||||
- `initialized`:已创建经确认的个人通知配置,未发送。
|
||||
- `accepted`:真实个人发送响应成功且含非空 `openTaskId`,请求已接受,不等于已投递、已读或接收端提醒成功。保留回执供排障,面向负责人只说中文结果。
|
||||
- `checked`/`preview`/`skipped`:分别为只读检查、离线预览、未配置或未启用;都没发送。
|
||||
- `error`:本次停止,按说明区分失败与未知,不自行补发。错误退出码为 1,参数错误可能为 2,不能只凭退出码 0 判断已发。
|
||||
|
||||
```text
|
||||
python scripts/dingtalk_notify.py message --title "联调需补充接口说明" --text "缺少接口返回值说明,联调暂时无法继续。请负责该接口的人补齐,并在自己的角色会话中同步结果。"
|
||||
```
|
||||
|
||||
这个入口允许自定义标题,但不新增项目状态,不替代应该记录的阻塞事件。需要 @ 已知负责人时追加 `--at "完整姓名"`;多个姓名用逗号分隔或重复传入 `--at`。正文也可用 `--file` 代替 `--text`。
|
||||
|
||||
- 负责人称呼来自 Etunel 已绑定的 `member_alias`,不要把角色名称 `display_name` 当人员姓名,也不要让人每轮确认身份。
|
||||
- 按姓名查找由封装执行,只使用部署时配置的同组织账号。没有配置账号、查不到、重名或返回无法识别时,本条不发送;请负责人澄清姓名或让部署负责人修正映射,不选择第一个、不换组织碰运气。
|
||||
- `--at-id "姓名=userId"` 仅兼容已有且同组织的有效映射,由真实工具结果或部署负责人提供;不从 Etunel 角色标识推导,不主动索要人员编号。
|
||||
- 默认发项目配置群。`--group` 仅保留给用户明确指定其他目标群的情形,不由 AI 猜群或扩大通知范围。
|
||||
- 同一事项只形成一条合并通知。已经由事件通知说明清楚时,不额外发一遍提醒;没有变化时不循环催促。
|
||||
- 通知必须指向实际下一步。负责人在线下讨论后回自己的角色会话补充结果,由角色完成原任务并经 Etunel 返回 Hub;群里已读或口头回复都不自动完成任务。
|
||||
|
||||
## 预览与结果
|
||||
|
||||
两个入口均支持 `--dry-run`;项目配置的预览开关也同时约束两者。预览不要求安装 `dws` 或填写密钥;会读取已有配置,但不使用密钥认证、不输出密钥、不查人员、不发送,只显示本地正文,@ 对象及目标配置未验证。
|
||||
|
||||
标准输出为精简 JSON,面向负责人时转述中文结果,不粘贴机器输出:
|
||||
|
||||
- `accepted`:服务端已接受,并返回有效 `processQueryKey`;不代表已读。
|
||||
- `preview`:只有预览,未发送。
|
||||
- `skipped`:未配置或未启用,未发送。
|
||||
- `error`:本次停止;按说明区分失败和结果未知,不声称已发送或自行补发。
|
||||
|
||||
前三种退出码为 0,错误为 1;错误参数可能返回 2,因此不能只凭退出码 0 宣布已发送。封装内部处理有限重试,不要在脚本外再套重试。缺配置、凭据、权限或安装问题交部署负责人处理;通知失败不影响 Etunel 主流程。
|
||||
需要进一步核实投递时,按发送回执在同一账号下调用 `dws chat +messages-query-send-status --open-task-id "真实回执值" --profile "配置账号" --format json` 只读查询;按实际结果判断,不把 `openTaskId` 当消息 ID。脚本只在明确 DNS 失败时最多尝试 3 次、间隔 10 秒;其余失败或未知结果不重发,Agent 不追加外层重试。登录失效交负责人恢复,不换身份或渠道;通知异常不阻塞主任务,也不递归发送异常通知。
|
||||
+1
@@ -23,6 +23,7 @@ Hub 只检查角色、必填内容、成果、版本、证据、负责人确认
|
||||
5. 从《项目概览》读取当前项目已经确认的特殊规则;只登记有权负责人明确确认的内容,并持续沿用到同一决定权人修改或取消,不在每项任务中重复询问。
|
||||
6. 在途项目继续使用已登记流程基线,除非取得明确迁移决定。
|
||||
7. 有权业务负责人发起的正式项目即使资料不齐也可接收,但缺口、责任和风险必须如实记录,不能把未知写成已确认。
|
||||
8. 首次使用钉钉通知时,按 [通知初始化](dingtalk-script-usage.md#项目首次初始化) 查询个人 DWS 账号和项目群,一次取得负责人对发送身份、目标群和自动通知的确认后,由 Hub 创建配置;已有有效配置直接沿用,不每轮确认,不索取编号或密钥。配置暂未完成不阻塞 Etunel 主任务。
|
||||
|
||||
## 启动阶段并明确完成约定
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-09-09T16:03:05Z"
|
||||
"syncedAt": "2026-09-10T23:43:18Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "e0966df11438b1f82e06d6f650a2a044b84457c3",
|
||||
"commit": "ccef3db535aa163a883f6ac000bdceb16b13e5e5",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-09-09T16:00:00Z"
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
}
|
||||
@@ -19,7 +19,7 @@ Enable Cache Components on an app and walk it to a passing build. This skill seq
|
||||
- `npx @next/codemod@latest upgrade latest` to apply the version-to-version codemods.
|
||||
- Read the relevant [version upgrade guide](https://nextjs.org/docs/app/guides/upgrading) (e.g. [Version 16](https://nextjs.org/docs/app/guides/upgrading/version-16)) for what the codemod doesn't cover.
|
||||
|
||||
- **No incompatible config keys.** `cacheComponents: true` errors on any file that still exports `dynamic`, `revalidate`, or `fetchCache`. **Translate, don't delete.** Each export encodes behavior the route needs to keep doing; migrate each one to its Cache Components equivalent via the [migration guide's per-key sections](https://nextjs.org/docs/app/guides/migrating-to-cache-components#enable-cache-components). The exception is `dynamic = 'force-dynamic'`: under Cache Components every route is already dynamic by default, so the migration guide removes it outright rather than translating it — don't overthink a batch of identical `force-dynamic` deletions. `revalidate` and `fetchCache` still need real translation. If a value can't be cleanly translated yet, leave a `// TODO: Cache Components adoption — restore revalidate = 3600` comment so the loop picks it up. The `cache-components-instant-false` codemod does not touch these.
|
||||
- **No incompatible config keys.** `cacheComponents: true` errors on any file that still exports `dynamic`, `revalidate`, or `fetchCache`. Inventory these exports before running the codemod, then follow the [migration guide's per-key sections](https://nextjs.org/docs/app/guides/migrating-to-cache-components). The guide is the source of truth for translating each value. The `cache-components-instant-false` codemod does not remove these configs.
|
||||
|
||||
- **`experimental.dynamicIO` is fatal.** It was renamed to top-level `cacheComponents` and the old key now aborts before any build can run — remove it (or replace with `cacheComponents: true`) first. `experimental.useCache` is still accepted as a deprecated alias; redundant once `cacheComponents: true` is set, so remove it for clarity.
|
||||
|
||||
@@ -27,6 +27,8 @@ Enable Cache Components on an app and walk it to a passing build. This skill seq
|
||||
|
||||
- **No passing baseline before the flag.** If the app already uses `"use cache"`, the pre-flag build errors with `please enable the feature flag cacheComponents`. Enabling the flag is the first thing you do (in Incremental, before the codemod; in Direct, before fixing routes) — not a thing to do _after_ getting a passing build. Note this in your starting summary so it doesn't read as a regression.
|
||||
|
||||
- **Existing caches can stay.** Follow the migration guide's [`fetch` and `unstable_cache` sections](https://nextjs.org/docs/app/guides/migrating-to-cache-components#fetch-cache-options). Do not rewrite them solely to enable Cache Components.
|
||||
|
||||
- **Offline docs.** Guide links have offline copies under `node_modules/next/dist/docs/` (bundled since Next.js 16.2), with the directory layout numbered for ordering (e.g. `node_modules/next/dist/docs/01-app/02-guides/migrating-to-cache-components.md`). If you can't predict the numbered prefix, `find node_modules/next/dist/docs -name '<slug>.md'` resolves it. The `/docs/messages/*` error pages are not bundled.
|
||||
|
||||
- **Older versions without bundled docs.** Suggest `npx @next/codemod@latest agents-md` to the user before starting: it downloads a version-matched copy to `.next-docs/` and writes an index into `AGENTS.md` / `CLAUDE.md`. It touches files in their repo, so ask first and run it only if they want it.
|
||||
@@ -37,7 +39,7 @@ There's one loop: walk the route tree top-down, one feature at a time, adopting
|
||||
|
||||
The choice in step 1 is whether to opt every route out of validation first or fix routes as you go. Either way the loop is the same:
|
||||
|
||||
- **With a quiet pre-step (Incremental).** Run the codemod to opt every page and layout out of validation. Once you've also fixed what the codemod can't (sync-IO calls, leftover `revalidate`/`dynamic`/`fetchCache` exports), the build passes; you ship that as its own PR and then start the loop — removing one opt-out at a time and adopting that route. This splits the work into small, reviewable PRs.
|
||||
- **With a quiet pre-step (Incremental).** Run the codemod, fix what it can't, and fully migrate routes that previously required static rendering. Other routes keep their opt-outs for follow-up PRs.
|
||||
- **Without (Direct).** Enable `cacheComponents` and start the loop on whatever the build flags first. Same loop, but every fix sits on one branch until adoption is complete.
|
||||
|
||||
In both, the per-route success bar is the same: **dev loop reports no errors AND `next build` passes**. Check in with the user after every feature, and suggest a commit but never make one without their confirmation. Expect to spend most of the time in the loop, not in the pre-step.
|
||||
@@ -46,7 +48,7 @@ In both, the per-route success bar is the same: **dev loop reports no errors AND
|
||||
|
||||
`cacheComponents: true` requires every route to be prerenderable. A route that reads request-time data outside `<Suspense>` is "blocking" and fails the build. `export const instant = false` marks a route as allowed to block, which clears it in both dev and build; on a layout it covers the whole subtree during the build, but client navigations still validate each descendant segment on its own. Reads wrapped in a [`"use cache"`](https://nextjs.org/docs/app/api-reference/directives/use-cache) function count as cache boundaries, not blocking reads.
|
||||
|
||||
When a fix introduces `"use cache"`, follow the Caching guide for [choosing a data-level or UI-level boundary and setting its lifetime](https://nextjs.org/docs/app/getting-started/caching#usage). Use the [`use cache` cache-key reference](https://nextjs.org/docs/app/api-reference/directives/use-cache#cache-keys) when the result varies by arguments or captured values.
|
||||
When a fix introduces `"use cache"`, follow the Caching guide for [choosing a data-level or UI-level boundary and setting its lifetime](https://nextjs.org/docs/app/getting-started/caching#usage) and [revalidating after mutations](https://nextjs.org/docs/app/getting-started/revalidating). Use the [`use cache` cache-key reference](https://nextjs.org/docs/app/api-reference/directives/use-cache#cache-keys) when the result varies by arguments or captured values.
|
||||
|
||||
Three classes of blocker come up, usually in this order:
|
||||
|
||||
@@ -99,14 +101,14 @@ Ask the user, in terms of the PRs they want, not the size of the job. Never use
|
||||
|
||||
If there's no user to ask, default to **Incremental** and document the choice.
|
||||
|
||||
Honor an explicit choice in the request. If the user asks to migrate incrementally and also asks you to complete the migration, establish and verify the incremental checkpoint before continuing to the remaining routes in the same task. “Complete” sets the stopping point; it does not change the chosen strategy.
|
||||
|
||||
- **Incremental** — quiet pre-step + the loop. Run the codemod to opt every page and layout out of validation, get the build passing, stop and check in with the user (see [end of the pre-step](#end-of-the-pre-step-check-in)), then enter [step 2's loop](#step-2-the-inner-loop-remove-opt-outs-one-feature-at-a-time) and ship each feature as a follow-up PR.
|
||||
- **Direct** — skip the pre-step. Enable `cacheComponents` and go straight to [step 2's loop](#step-2-the-inner-loop-remove-opt-outs-one-feature-at-a-time); the build's blocking routes are the work queue.
|
||||
|
||||
### incremental
|
||||
|
||||
Before invoking the codemod, fix the blocker that does not require build feedback.
|
||||
|
||||
1. **Incompatible segment configs.** Grep for `^export const (revalidate|dynamic|fetchCache)` across the app directory and translate per the `requires` note above. The codemod does not touch them; leaving them in place fails the build after the codemod.
|
||||
Before invoking the codemod, grep for `^export const (revalidate|dynamic|fetchCache)` across the app directory and follow the migration guide for every match. Mark routes that use `dynamic = 'force-static'` or `dynamic = 'error'` for full migration in this PR. The codemod does not remove incompatible configs.
|
||||
|
||||
The codemod refuses to run on a dirty working tree. Commit or stash unrelated work first, or pass `--force` to let its edits land alongside your WIP. Common false positive: if you recently upgraded Next.js, `package.json` and the lockfile will already be dirty — commit those first.
|
||||
|
||||
@@ -130,6 +132,8 @@ The codemod opts every segment out, not only the root, on purpose. Resolution is
|
||||
|
||||
Because the highest opt-out wins, remove them top-down (root layout first, then descend). Removing a leaf's opt-out does nothing while an ancestor still holds one.
|
||||
|
||||
Remove the opt-outs from the previously static routes and any shared segments that mask their validation. Resolve their errors and confirm they still prerender. A cached data call alone does not prove the route remains static.
|
||||
|
||||
Next, run `next build` to surface blockers the codemod could not handle. The build is the proof, not the codemod run — a shared layout that calls `new Date()` / `Math.random()` directly still fails regardless of the opt-out (see [background](#background)). If the normal build reports a sync-IO error without locating the call, rerun that route with `next build --debug-prerender --debug-build-paths="app/path/to/page.tsx"`. For each sync-IO error it reports:
|
||||
|
||||
1. **Sync-IO at module/render time.** Use the route, originating file and line, and `/docs/messages/` link in the build output to locate the error. If needed, grep the whole repo for `new Date()`, `Date.now()`, `Math.random()`, and `crypto.randomUUID()` (not only `app/**/layout.{js,jsx,ts,tsx}` — the read might live in any component imported by a layout). Do not change unreported matches. Apply the appropriate option from the linked error page, then add this comment above a temporary boundary introduced only to unblock the build:
|
||||
@@ -142,20 +146,20 @@ Next, run `next build` to surface blockers the codemod could not handle. The bui
|
||||
|
||||
After each fix, rerun the scoped build when available, then run `next build` again to find the next blocker. Repeat until the normal build passes.
|
||||
|
||||
After the build passes, confirm the root layout got an opt-out (`grep -n "export const instant" <app dir>/layout.*`). The root layout renders every route, including framework routes like `/_not-found`, so if it was missed, add `export const instant = false` to it by hand.
|
||||
After the build passes, confirm every deferred route is still covered by an opt-out and no shared opt-out covers a previously static route. If the app has no previously static routes and the root layout remains deferred, confirm it got an opt-out (`grep -n "export const instant" <app dir>/layout.*`). The root layout renders every route, including framework routes like `/_not-found`, so if it was missed, add `export const instant = false` to it by hand.
|
||||
|
||||
Synthetic routes like `/_not-found` have no user file — when they block, fix the root layout's opt-out, not the synthetic route. Client Components (`"use client"`) get no opt-out (it's a build error to export `instant` from them), but they are not a rare blocker. The high-frequency case is a client component in the root layout's nav or header calling `usePathname()`/`useSearchParams()`: it blocks _every_ dynamic route with `blocking-prerender-client-hook`, and static routes pass (the pathname is known at prerender), which masks it until you reach a dynamic segment. It's not an ancestor-data fix — follow the [error's docs page](https://nextjs.org/docs/messages/blocking-prerender-client-hook) for the `<Suspense>` recipe. Only when a client route blocks on _server_ data do you fix that data in its ancestor.
|
||||
|
||||
### end of the pre-step: check in
|
||||
|
||||
Incremental only. Stop here before starting step 2 — the pre-step is the shippable PR. Talk to the user in their language; don't say "Incremental" or other internal labels; talk about adoption, PRs, and what the app does now. Tell them:
|
||||
Incremental only. The pre-step is the shippable PR. Record the passing checkpoint before starting step 2. Unless the user already asked you to continue through the full migration in the same task, stop and check in. Talk to the user in their language; don't say "Incremental" or other internal labels; talk about adoption, PRs, and what the app does now. Tell them:
|
||||
|
||||
- What you did: turned on Cache Components, ran the codemod that opts every page and layout out of the new validation (or did it by hand), fixed any blockers the codemod can't (list them), confirmed the build passes.
|
||||
- What changed: every page and layout in the app directory now exports `instant = false` with a `// TODO: Cache Components adoption` comment, except client components and any that already had an `instant` export.
|
||||
- What to sanity-check: the diff is mostly mechanical (new exports + comments). The build passes. Routes still behave exactly as they did before — the opt-outs preserve current behavior; no rendering changes yet.
|
||||
- What you did: turned on Cache Components, ran the codemod, migrated the previously static routes, fixed the remaining blockers, and confirmed the build passes.
|
||||
- What changed: the previously static routes still prerender. Other pages and layouts keep a `// TODO: Cache Components adoption` opt-out.
|
||||
- What to sanity-check: the previously static routes stay fully prerendered and prefetchable, and request-specific data on the deferred routes remains request-specific.
|
||||
- The question: "Want to open this as its own PR before we start adopting Cache Components route by route? Or keep going on this branch?" Wait for the answer.
|
||||
|
||||
Moving to step 2 without checking in defeats the point of taking the incremental path.
|
||||
If the user already asked you to complete the migration, continue after recording this checkpoint instead of asking the same question again.
|
||||
|
||||
### direct
|
||||
|
||||
@@ -209,6 +213,7 @@ Checklist before checking in with the user:
|
||||
- `next build` completes without blocking-route errors.
|
||||
- No bare TODOs in the feature: `grep -rn "TODO: Cache Components adoption"` finds both the codemod's opt-out comments and the sync-IO unblocks from the pre-step. Any `instant = false` left behind is a deliberate, documented Block — comment rewritten to a reason (see [references/per-page-decisions.md](./references/per-page-decisions.md) → "when to leave a Block in place"). Any `await io()` or `await connection()` left behind has been reviewed and kept on purpose, not left over from the pre-step.
|
||||
- Each route visited in the browser: confirm the static shell renders first and every `<Suspense>` fallback resolves to its real content. Capture both states if you can — the fallback (mid-stream) and the final paint — so you have a streaming-experience demo to show the user. Throttle the network in the browser if streaming is too fast to observe.
|
||||
- After populating any new cache whose data can be updated, a mutation check confirms the next read returns the expected data.
|
||||
- If runtime verification fails, reproduce the same route on the pre-adoption branch or with its opt-out restored. A failure that already exists is an environment or data problem, not an adoption regression.
|
||||
|
||||
Then check in with the user. Same rule as the pre-step: speak their language. Don't say "feature-by-feature loop" or other internal labels; talk about the feature you adopted and what the user will see.
|
||||
|
||||
@@ -157,9 +157,8 @@ yours. On first use in a repository, discover how the project builds, deploys,
|
||||
authenticates, and tests (inspect the repository first, and ask the user only
|
||||
what it cannot answer), then write the answers to a committed
|
||||
`instant-nav.rig.md`. Every later run reads that file instead of
|
||||
rediscovering. The six questions (BUILD / EXPOSE / RUN / TEST USER / DRIFT /
|
||||
LOOP), the file template, and filled examples (local-only, generic CI +
|
||||
container, preview deploy) are in **`rig-template.md`**.
|
||||
rediscovering. The required build, test context, navigation contracts,
|
||||
iteration loop, and file template are in **`rig-template.md`**.
|
||||
|
||||
If the repo has no Playwright e2e harness yet, standing up a minimal one
|
||||
(`@next/playwright`, a config with `baseURL`, one authenticated path) is part
|
||||
@@ -196,12 +195,12 @@ every platform:
|
||||
The rig is any production-like build that exposes the testing API: a local
|
||||
`next build && next start`, a CI/staging container, and a preview deploy are
|
||||
all equally valid; the verdict comes from the build, not the platform. See
|
||||
`rig-template.md` for filled examples.
|
||||
`rig-template.md` for the setup requirements.
|
||||
|
||||
For any deployed or remote build, poll the rig's LIVENESS probe to confirm the
|
||||
artifact contains `HEAD` before trusting a verdict (a stale deploy reads as a
|
||||
false RED or GREEN); a local `next build && next start` needs none. The probe
|
||||
mechanism is in `rig-template.md` (question 6).
|
||||
mechanism is in `rig-template.md`.
|
||||
|
||||
## B. BASELINE (unlocked): development scaffold, do not ship
|
||||
|
||||
@@ -357,6 +356,10 @@ this gate is as machine-checkable as the others. Detail:
|
||||
> under the lock on the production-build rig**, not when the code compiles. That
|
||||
> GREEN is the deterministic stop for the fix loop; proceed to E.
|
||||
|
||||
If the optimization adds or expands a cache boundary, follow
|
||||
[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating).
|
||||
A passing `instant()` test proves shell readiness, not mutation freshness.
|
||||
|
||||
**When URL data can't be pushed down** (for example, the whole page depends on
|
||||
`params`, `searchParams`, or the full URL), there may be no meaningful static
|
||||
shell to grow. Don't force one. Per-link prefetching can make the soft
|
||||
@@ -404,6 +407,9 @@ PR-specific items:
|
||||
|
||||
- [ ] **Differential shown**: RED without the fix, GREEN with it, runs linked.
|
||||
- [ ] **Parity confirmed (E)**: same content, redirects, and state.
|
||||
- [ ] **Mutations verified when applicable**: after populating any cache whose
|
||||
data can be updated, a mutation test confirms the next read returns the
|
||||
expected data.
|
||||
- [ ] **Existing loading UI reused (D1)**: no new page-mirroring skeleton.
|
||||
- [ ] **Shell matches the real render at desktop and mobile widths (D2)**.
|
||||
- [ ] **Baseline removed**: only the locked test from C remains.
|
||||
@@ -425,9 +431,8 @@ three hold, you are not done.
|
||||
|
||||
## Files
|
||||
|
||||
- `rig-template.md`: phase 0, the six-question rig discovery, the
|
||||
`instant-nav.rig.md` template, and filled examples (local-only, generic CI,
|
||||
preview deploy).
|
||||
- `rig-template.md`: phase 0 production build, test context, navigation
|
||||
contract, and unattended loop discovery.
|
||||
- `test-template.md`: the shipped `instant()` specs for both navigation
|
||||
types (phase C), and the delete-before-PR baseline scaffold (phase B).
|
||||
- `reference/red-test-robustness.md`: the C-gate and phase F. The taxonomy of
|
||||
|
||||
+153
-91
@@ -1,111 +1,173 @@
|
||||
# Rig discovery: generate this project's `instant-nav.rig.md`
|
||||
# Production `instant()` rig
|
||||
|
||||
The skill's principles are environment-independent. Your build, deploy, auth,
|
||||
and test infrastructure are not. This phase converts the principles into THIS
|
||||
project's concrete workflow: run discovery once per repo, write the answers to
|
||||
a committed `instant-nav.rig.md` (repo root, or next to your e2e config), and
|
||||
every later run reads that file instead of rediscovering.
|
||||
The optimizer suite needs a production build that exposes the Next.js testing
|
||||
API, a stable URL for that build, and a Playwright command that can drive the
|
||||
initial loads or client navigations being optimized. Discover this setup once,
|
||||
record it in `instant-nav.rig.md`, and reuse it across optimization runs.
|
||||
|
||||
The skill is deliberately opinionated about **what** the rig must provide, and
|
||||
deliberately unopinionated about **how** your stack provides it.
|
||||
Read an existing `instant-nav.rig.md` before creating one. Inspect the
|
||||
repository before asking the user:
|
||||
|
||||
## How to discover
|
||||
- `package.json` scripts for build, start, and end-to-end tests
|
||||
- `playwright.config.*` for `baseURL`, `webServer`, projects, and authentication
|
||||
- `next.config.*` for existing `experimental` options
|
||||
- CI, preview deployment, container, and hosting configuration
|
||||
- test helpers for login, `storageState`, fixtures, flags, and seeded data
|
||||
|
||||
Inspect before asking. Most answers are already in the repo:
|
||||
Ask only for details the repository cannot answer, such as unavailable
|
||||
credentials or which remote environment may expose the testing API.
|
||||
|
||||
- `package.json` scripts (`build`, `start`, `test:e2e`)
|
||||
- the e2e config (`playwright.config.*`: `baseURL`, `webServer`, projects)
|
||||
- CI config (`.github/workflows/`, `vercel.json`, GitLab/Circle files,
|
||||
Dockerfiles)
|
||||
- `next.config.*` (existing `experimental` flags)
|
||||
- existing e2e auth helpers (grep for `login`, `storageState`, `session`)
|
||||
## What the rig must define
|
||||
|
||||
Ask the user only what the repo can't answer. Typically that means: which
|
||||
deploy target counts as "preview", which account the suite runs as in CI, and
|
||||
whether an agent is allowed to push and wait on CI unattended.
|
||||
### Production build and server
|
||||
|
||||
## The six questions (all must have answers), plus two derived fields
|
||||
Use `next build` followed by `next start`, or a remote artifact produced by the
|
||||
same production build. Development can help diagnose a route, but the final
|
||||
RED and GREEN must come from a production build.
|
||||
|
||||
The six questions below must all have answers. The rig file template adds two
|
||||
more fields the discovery feeds rather than asks directly: **LIVENESS** (the
|
||||
SHA-echoing probe, derived from the LOOP answer) and **WALLS** (project-specific
|
||||
build/run obstacles, accumulated as you first hit them).
|
||||
Record separate build and start commands. For a local rig, record the port,
|
||||
stop any previous server before starting, fail on `EADDRINUSE`, and confirm the
|
||||
new process owns the port before running Playwright. `next start` can fork a
|
||||
`next-server` child, so the launcher process ID may not own the port. Start the
|
||||
server in a process group that the rig can stop as a unit, or discover and stop
|
||||
the process listening on the recorded port before the next build.
|
||||
|
||||
1. **BUILD**: how is a production build of this app produced and served?
|
||||
A per-push preview deploy, a staging container, or bare
|
||||
`next build && next start`. Anything but `next dev`.
|
||||
2. **EXPOSE**: what condition turns on
|
||||
`experimental.exposeTestingApiInProductionBuild` for every measured build,
|
||||
and never for real production? Spellings: an explicit
|
||||
`EXPOSE_TESTING_API=1` for local production builds; `process.env.DEPLOY_ENV
|
||||
=== 'staging'` for a generic CI/staging env var; `process.env.VERCEL_ENV ===
|
||||
'preview'` on Vercel. Set the condition during `next build`, not only
|
||||
`next start`. Otherwise `instant()` may not acquire the testing cookie
|
||||
before the test times out; rebuild the artifact before debugging the
|
||||
assertion.
|
||||
3. **RUN**: how is the Playwright suite invoked, and against which
|
||||
`BASE_URL`?
|
||||
4. **TEST USER**: which account does the suite run as, and how does login
|
||||
happen (helper, `storageState`, API token)? What flags / plan / role / data
|
||||
does that account have?
|
||||
5. **DRIFT**: enumerate everything that can differ between the author's own
|
||||
session and the test user's environment (feature flags, plans and
|
||||
entitlements, roles, seeded vs empty data, locale, A/B buckets). Every item
|
||||
is a way a RED can become untrustworthy; this list feeds the C-gate
|
||||
(`reference/red-test-robustness.md`).
|
||||
6. **LOOP**: the unattended iteration for your rig. Push → build → e2e
|
||||
against the artifact → read the failure → fix → push (CI), or build → start
|
||||
→ e2e (local). Note anything an agent cannot do alone (deploy approvals,
|
||||
secrets, protected branches). Include the **liveness probe**: the endpoint
|
||||
or response header that echoes the deployed commit SHA (e.g. a `/healthz`
|
||||
route or an `x-deployed-sha` header), so a CI run can confirm the build
|
||||
under test matches `HEAD` before trusting a verdict (SKILL.md phase A). If
|
||||
the platform exposes no SHA-echoing endpoint or header, add one: surface a
|
||||
build-time commit var (`VERCEL_GIT_COMMIT_SHA`, a CI commit variable) on a
|
||||
`/healthz` route or a response header, or fall back to polling the deploy
|
||||
platform's API for the deployment whose `commitSha === HEAD`. Record the
|
||||
chosen mechanism. For a local `build && start` rig the artifact is the one
|
||||
freshly built, so no SHA probe is needed. Record the port, stop the previous
|
||||
server before starting, fail the loop on `EADDRINUSE`, and verify the newly
|
||||
started process owns the port before running the test. `next start` can fork
|
||||
a `next-server` child, so the launcher process ID may not own the port. Start
|
||||
the server in a process group that the rig can stop as a unit, or discover
|
||||
and stop the process listening on the recorded port before the next build.
|
||||
### Testing API
|
||||
|
||||
## The file: copy, fill, commit as `instant-nav.rig.md`
|
||||
An `instant()` test against a production build requires
|
||||
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
|
||||
builds do not expose the API:
|
||||
|
||||
```ts filename="next.config.ts" highlight={3,8-10}
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const exposeTestingApi = process.env.EXPOSE_TESTING_API === '1'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
cacheComponents: true,
|
||||
experimental: {
|
||||
exposeTestingApiInProductionBuild: exposeTestingApi,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
```
|
||||
|
||||
Merge the option into an existing `experimental` object instead of replacing
|
||||
the project's other experimental options.
|
||||
|
||||
Set the condition while running `next build`. Setting it only for `next start`
|
||||
is too late because the testing API is compiled into the production artifact.
|
||||
When the artifact was built without it, Next.js does not activate the
|
||||
navigation lock, so the test cannot distinguish shell content from streamed
|
||||
dynamic content. Rebuild with the condition enabled before interpreting the
|
||||
results. Use the project's existing environment naming when it already
|
||||
distinguishes test, staging, preview, and production builds.
|
||||
|
||||
### Test command and base URL
|
||||
|
||||
Record the exact Playwright command and how it receives the measured build's
|
||||
URL. Reuse the project's package manager, Playwright configuration, projects,
|
||||
and reporters. The suite must import `instant()` from `@next/playwright`. If
|
||||
the dependencies are absent, install `@next/playwright` on the same release
|
||||
line as the project's `next`, alongside `@playwright/test`.
|
||||
|
||||
For a local rig, a typical sequence is:
|
||||
|
||||
```bash filename="Terminal"
|
||||
EXPOSE_TESTING_API=1 pnpm build
|
||||
pnpm start --port 3000
|
||||
BASE_URL=http://localhost:3000 pnpm playwright test tests/static-shell.spec.ts
|
||||
```
|
||||
|
||||
Adapt the script names and port to the project. Keep the production server
|
||||
running while the test command executes. Follow the public
|
||||
[`instant()` testing pattern](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests): use `page.goto()` for an initial-load
|
||||
contract and click the real `<Link>` for a client-navigation contract.
|
||||
|
||||
### Test context
|
||||
|
||||
Record the state required to reach the target route and shell marker:
|
||||
|
||||
- Use `public; no authentication` when the route is public.
|
||||
- Otherwise record the test account and login mechanism, including a fixture,
|
||||
`storageState`, API login, or seeded session.
|
||||
- Record flags, plan, role, locale, seeded data, and other state that can change
|
||||
which shell the test sees.
|
||||
|
||||
A test user is not required. The field exists to make authenticated and
|
||||
state-dependent tests reproducible when the app needs one.
|
||||
|
||||
### Drift
|
||||
|
||||
List differences between the state used to choose the shell contract and the
|
||||
state used by Playwright. Feature flags, permissions, empty test data, and
|
||||
locale differences can make an assertion fail because the target is
|
||||
unreachable, not because its shell blocks. Write `none known`
|
||||
only after checking the test context.
|
||||
|
||||
### Iteration loop
|
||||
|
||||
Record the complete loop the agent can repeat without rediscovering commands:
|
||||
|
||||
- Local: build with the testing API, start the new artifact, run the focused
|
||||
suite, stop the server, edit, and repeat.
|
||||
- Remote: push, wait for the measured artifact, verify it matches `HEAD`, run
|
||||
the focused suite against its URL, edit, and repeat.
|
||||
|
||||
Note any step the agent cannot perform without the user, including deployment
|
||||
approval, protected branches, secrets, or multi-factor authentication.
|
||||
|
||||
### Artifact liveness
|
||||
|
||||
For a remote rig, record how the test proves the deployment matches `HEAD`.
|
||||
Prefer an endpoint or response header that exposes the deployed commit SHA. If
|
||||
the app has neither, use the deployment provider's API to select the artifact
|
||||
whose commit SHA matches `HEAD`.
|
||||
|
||||
A freshly completed local `build` followed by `start` does not need a SHA
|
||||
probe. Record `n/a; local build and start`.
|
||||
|
||||
### Walls
|
||||
|
||||
Record build and run obstacles with their working resolution, such as required
|
||||
environment variables, server-only imports that fail during prerendering,
|
||||
unavailable credentials, or a process that keeps reclaiming the test port.
|
||||
Reuse these notes on the next iteration.
|
||||
|
||||
## Write `instant-nav.rig.md`
|
||||
|
||||
Place this file at the repository root or next to the end-to-end configuration:
|
||||
|
||||
```md
|
||||
# instant-nav rig: <project>
|
||||
|
||||
- BUILD: <command / platform that produces the measured production build>
|
||||
- EXPOSE: <the condition wired to exposeTestingApiInProductionBuild>
|
||||
- RUN: <e2e command> against <how BASE_URL is obtained>
|
||||
- TEST USER: <account> via <login mechanism>; flags/plan/role/data: <...>
|
||||
- DRIFT: <the enumerated drift surface>
|
||||
- LOOP: <push → CI → e2e, or local build → start → test>; agent limits: <...>
|
||||
- LIVENESS: <endpoint/header echoing the deployed SHA; n/a for local build && start>
|
||||
- WALLS: <project-specific build/run obstacles + their workarounds>
|
||||
- BUILD: <commands or platform that builds and serves the measured production artifact>
|
||||
- EXPOSE: <condition that enables exposeTestingApiInProductionBuild during build>
|
||||
- RUN: <focused Playwright command and how it receives BASE_URL>
|
||||
- TEST USER: <public/no auth, or account and login>; state: <flags, role, data, locale>
|
||||
- DRIFT: <differences that could change the asserted UI>
|
||||
- CONTRACTS: <route, initial load or source Link, shell marker, and deferred marker>
|
||||
- LOOP: <local build → start → test, or push → deploy → test>; agent limits: <...>
|
||||
- LIVENESS: <deployed SHA check, or n/a for a local build and start>
|
||||
- WALLS: <project-specific obstacles and their resolutions>
|
||||
```
|
||||
|
||||
Real apps rarely build for production cleanly on the first attempt: missing
|
||||
secrets, server-only imports that fail prerender, ports held by respawning
|
||||
servers. Record each wall and its workaround the first time you hit it. `WALLS`
|
||||
accumulates the project-specific build/run obstacles that the other fields
|
||||
cannot capture.
|
||||
`CONTRACTS` may list more than one focused navigation, but each route and
|
||||
navigation type needs its own test. Every field needs a concrete value. `n/a`
|
||||
is valid only with a reason, such as `TEST USER: public; no authentication` or
|
||||
`LIVENESS: n/a; local build and start`.
|
||||
|
||||
## Filled examples
|
||||
## Check the rig before writing the baseline
|
||||
|
||||
**No CI / local-only.** BUILD: `EXPOSE_TESTING_API=1 next build && next
|
||||
start`. EXPOSE: that env var. RUN: `BASE_URL=http://localhost:3000 playwright
|
||||
test`. LOOP: build → start → test on one machine; fully agent-drivable, with
|
||||
nothing to push, no secrets, and no deploy wait.
|
||||
Before recording the static-shell contract:
|
||||
|
||||
**Generic CI + container.** BUILD: the pipeline builds an image and deploys it
|
||||
to a staging namespace. EXPOSE: `process.env.DEPLOY_ENV === 'staging'`. RUN: a
|
||||
CI job runs Playwright against the staging URL. LOOP: push → pipeline → e2e;
|
||||
fully agent-drivable once the pipeline is wired.
|
||||
1. Build with the testing API condition enabled.
|
||||
2. Start or locate that exact artifact and confirm the base URL responds.
|
||||
3. Run one focused `instant()` smoke test using the intended navigation type.
|
||||
4. Confirm the test can reach the route and eventual destination UI in the
|
||||
recorded test context.
|
||||
|
||||
**Vercel preview deploys.** BUILD: every push builds a preview. EXPOSE:
|
||||
`process.env.VERCEL_ENV === 'preview'`. RUN: `playwright test` with
|
||||
`BASE_URL=<preview URL>`. LOOP: push → preview → e2e; fully agent-drivable
|
||||
once the preview deploy and `VERCEL_ENV` gating are in place.
|
||||
Fix the rig before interpreting an optimizer failure. A missing testing API,
|
||||
stale deployment, unreachable target, or wrong test state is an environment
|
||||
failure rather than evidence that the shell blocks.
|
||||
@@ -112,7 +112,7 @@ Use that exact prefix so step 7 can grep them back. Do not select new target UI
|
||||
|
||||
For test-backed preservation, rerun the affected **unchanged** tests after each destination changes and treat failures as the work queue. Run the complete suite and record its passing exit status before enabling the global flag. For manual preservation, compare the adopted production navigation with the selected target and document anything not yet restored. Apply the guide's matching preservation pattern for caching and Link-prop changes, and ask the user before making an unclear freshness or caching decision. New URL-data candidates marked above wait for step 7.
|
||||
|
||||
When restoring the target changes caching or invalidation, follow the project's existing verification approach. Reuse or extend an applicable suite for the affected lifecycle, such as freshness after mutations, cache scope, or generated values. If the project doesn't test this type of behavior, do not introduce new test infrastructure during adoption; verify it manually in production and record the expected and observed results. A green `instant()` test proves readiness, not cache correctness. Ask the user only when the intended behavior is unclear.
|
||||
When restoring the target changes caching or invalidation, follow the project's existing verification approach and the [Revalidating](https://nextjs.org/docs/app/getting-started/revalidating) guide. Reuse or extend an applicable suite for the affected lifecycle. If the project doesn't test this type of behavior, do not introduce new test infrastructure during adoption; verify it manually in production and record the expected and observed results. A green `instant()` test proves readiness, not cache correctness. Ask the user only when the intended behavior is unclear.
|
||||
|
||||
> **If you add `use cache`, verify under `next start`, not only the build.** A `cookies()`/`headers()`/session read anywhere in the cached call tree throws at request time while `next build` passes clean. See [`use cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache).
|
||||
|
||||
@@ -153,7 +153,7 @@ Checklist before checking in with the user:
|
||||
- The App Shells are real: for each route you changed, confirm the first paint after a navigation shows the intended shared content, not an empty shell or a stuck fallback. A `<Suspense>` around the whole page body passes validation with an empty shell, which defeats the point.
|
||||
- The insights validate shell _structure_, not that a prefetch actually happened. Confirm on the production run (automatic prefetching runs only in production) that navigating a changed link lands on the shared shell instantly.
|
||||
- For test-backed preservation, every locked `instant()` test for an audited `<Link prefetch={true}>` passes against the production run. For manual preservation, the before/after inventory and any deferred test follow-ups are recorded.
|
||||
- Any caching or invalidation changed to preserve the target is verified through an applicable existing test suite or a recorded manual check when the project has no such coverage.
|
||||
- After populating any new or expanded cache whose data can be updated, a mutation check verifies the next read through an applicable existing test suite or a recorded manual check when the project has no such coverage.
|
||||
- **If the app prefetches imperatively**, the insight sweep does not cover it, so an empty sweep is not proof the prefetch survived the flag. Verify the call under `next start`: compare the `_rsc` prefetch response or resource timing before/after, and make sure any intentionally preserved full prefetch still carries the data the old call was warming. If it now returns only the App Shell, migrate that call site using the same decision as the nearest `<Link prefetch={true}>` destination — cache the data, or move per-link-prefetch behavior to a docs-supported `<Link prefetch={true}>`.
|
||||
- **Before blaming a broken route on the flag, reproduce it with `partialPrefetching` off** (or on the pre-flag branch). The flag surfaces existing issues — a fragile request-time auth gate, a rewrite, deployment skew — earlier and more visibly, but rarely causes them. If it breaks flag-off too, it isn't a Partial Prefetching problem; fix it there, not here.
|
||||
- `next build` still passes.
|
||||
|
||||
+5
-3
@@ -148,14 +148,16 @@ Place this file at the repository root or next to the end-to-end configuration:
|
||||
- RUN: <focused Playwright command and how it receives BASE_URL>
|
||||
- TEST USER: <public/no auth, or account and login>; state: <flags, role, data, locale>
|
||||
- DRIFT: <differences that could change the asserted UI>
|
||||
- CONTRACTS: <audited source Link, destination, and prefetched UI to preserve>
|
||||
- LOOP: <local build → start → test, or push → deploy → test>; agent limits: <...>
|
||||
- LIVENESS: <deployed SHA check, or n/a for a local build and start>
|
||||
- WALLS: <project-specific obstacles and their resolutions>
|
||||
```
|
||||
|
||||
Every field needs a concrete value. `n/a` is valid only with a reason, such as
|
||||
`TEST USER: public; no authentication` or `LIVENESS: n/a; local build and
|
||||
start`.
|
||||
`CONTRACTS` may list more than one audited navigation, but each contract needs
|
||||
its own test. Every field needs a concrete value. `n/a` is valid only with a
|
||||
reason, such as `TEST USER: public; no authentication` or
|
||||
`LIVENESS: n/a; local build and start`.
|
||||
|
||||
## Check the rig before writing the baseline
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
name: next-partial-prefetching-optimizer
|
||||
description: >
|
||||
Optimize what selected Next.js client navigations include before the click
|
||||
under Partial Prefetching. Use after Cache Components and Partial Prefetching
|
||||
are adopted when the user wants selected URL-specific UI to be instant,
|
||||
wants reusable content to wait for navigation, or needs to choose between
|
||||
default, viewport, and intent prefetching. Requires Next.js 16.3+.
|
||||
---
|
||||
|
||||
# Partial Prefetching optimizer
|
||||
|
||||
Optimize each requested source link and destination as its own contract. Turn
|
||||
the requested prefetched UI, navigation-only UI, and trigger into a production
|
||||
[`instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests)
|
||||
test. Record the current behavior, make the smallest optimization, verify the
|
||||
differential, and keep the passing test as regression coverage.
|
||||
|
||||
Before making framework changes, read the bundled Optimizing prefetching guide
|
||||
at
|
||||
`node_modules/next/dist/docs/01-app/02-guides/optimizing-prefetching.md`. If the
|
||||
bundled guide is unavailable, use the [online
|
||||
guide](https://nextjs.org/docs/app/guides/optimizing-prefetching). It is the
|
||||
source of truth for prefetch stages, `prefetch={true}`, session-specific UI,
|
||||
and cost trade-offs.
|
||||
|
||||
When the work changes what belongs in the App Shell, follow the
|
||||
[Instant navigation](https://nextjs.org/docs/app/guides/instant-navigation) and
|
||||
[Caching](https://nextjs.org/docs/app/getting-started/caching) docs for cache
|
||||
placement, Suspense boundaries, loading UI, and authorization.
|
||||
|
||||
The guides own framework behavior and implementation patterns. This skill owns
|
||||
the navigation contract, production rig, trustworthy RED-to-GREEN loop, parity
|
||||
check, differential, and report.
|
||||
|
||||
This is not an adoption skill. If Cache Components or Partial Prefetching are
|
||||
not adopted, use their adoption skills and return to this workflow. If the App
|
||||
Shell itself cannot commit under `instant()`, use
|
||||
`next-cache-components-optimizer` first, then resume the selected navigation.
|
||||
|
||||
Run the workflow unattended. Resolve the contract from the request and the
|
||||
existing application. Stop for user input only when a product decision is
|
||||
genuinely blocked and cannot be inferred safely without changing cost,
|
||||
freshness, authorization, or user-visible behavior.
|
||||
|
||||
## Reporting to the user
|
||||
|
||||
This loop runs unattended, so do not stop between implementation steps. Finish
|
||||
the navigations the user named, then check in. The rig, lock, RED/GREEN loop,
|
||||
and stage names are internal scaffolding; report the product behavior instead.
|
||||
|
||||
- **Speak their language.** Describe the source link and result in terms of
|
||||
what the user sees before and after the click, not the validation mechanics.
|
||||
- **Show, don't tell.** Drive the exact link in a production browser so the user
|
||||
sees which content is already available and which content streams after the
|
||||
click. If a live demonstration is unavailable, attach before/after captures.
|
||||
- **Give them a concise click-through list, not a technical results table.** Use
|
||||
one line per navigation with the source URL, link to click, UI ready before
|
||||
the click, UI that waits for navigation, and whether prefetching starts in the
|
||||
viewport or after intent.
|
||||
- **Only surface a question for a genuine fork.** Ask when the choice changes
|
||||
cost, freshness, authorization, or visible behavior. If the user already
|
||||
requested a PR or named every navigation, finish that scope without asking
|
||||
again.
|
||||
|
||||
## Define the contract
|
||||
|
||||
Inspect the source route, the exact link or interaction, the destination's
|
||||
Suspense boundaries, its data reads, existing prefetch policy, and any existing
|
||||
`instant()` test. Record:
|
||||
|
||||
- which destination UI should be ready before the click;
|
||||
- which reusable UI should wait for navigation;
|
||||
- whether per-link prefetching should start in the viewport or only after
|
||||
intent.
|
||||
|
||||
The trigger is part of the contract. Two links to the same URL may use
|
||||
different prefetch policies and need separate tests.
|
||||
|
||||
Use the guide's cost model when the trigger is not already specified. Do not
|
||||
silently increase the number of links that can invoke the server or cache data
|
||||
whose freshness contract is unknown.
|
||||
|
||||
## Reuse the production rig
|
||||
|
||||
Read an existing `instant-nav.rig.md`. Cache Components optimization, Partial
|
||||
Prefetching adoption, and this optimizer share the same build, auth, data, and
|
||||
Playwright contract. Add the exact source link, destination markers, and
|
||||
prefetch budget instead of creating another rig.
|
||||
|
||||
If the project has no rig, use [`rig-template.md`](rig-template.md) to discover
|
||||
and record one. The measured run must be a production build or
|
||||
preview where `experimental.exposeTestingApiInProductionBuild` is enabled only
|
||||
for testing. Development can help diagnose a route, but automatic link
|
||||
prefetching is production-only.
|
||||
|
||||
## Prove the current behavior
|
||||
|
||||
Search for an existing `instant()` test for the exact source link and
|
||||
destination. Extend it when it already covers the same behavior. Otherwise,
|
||||
add a focused click-driven test using the guide's
|
||||
[prefetched and deferred content testing](https://nextjs.org/docs/app/guides/optimizing-prefetching#test-prefetched-and-deferred-content)
|
||||
pattern.
|
||||
|
||||
Keep one production browser test per source-link, destination, and trigger
|
||||
contract. Do not loop over several destinations or collect their readiness
|
||||
results in one test. Focused tests can still run serially in one browser worker.
|
||||
|
||||
First, run an unlocked scaffold that proves the link reaches the exact pathname
|
||||
and query and that the selected UI eventually renders for the test user. Do not
|
||||
ship this scaffold.
|
||||
|
||||
Then run the same interaction inside `instant()`. The existing App Shell must
|
||||
stay visible, while the guide's positive and negative assertions capture the
|
||||
prefetched result. After the lock releases, every selected region must
|
||||
eventually render.
|
||||
|
||||
Read [`reference/red-test-robustness.md`](reference/red-test-robustness.md)
|
||||
before treating this failure as RED. A timeout before the URL changes, a
|
||||
missing App Shell, a redirect, missing data, or a stale preview indicates a rig
|
||||
or route problem. It does not justify changing prefetching.
|
||||
|
||||
If the complete desired contract already passes under the lock, stop. Never
|
||||
add `prefetch={false}` merely to manufacture a RED.
|
||||
|
||||
Only test files and the configuration needed to expose the testing API may
|
||||
change before these runs finish. Run builds and tests in the foreground. If the
|
||||
harness moves one into the background, wait for it to exit and continue the
|
||||
loop in the same task. A written test or a build still in progress is not
|
||||
verification.
|
||||
|
||||
## Make the smallest optimization
|
||||
|
||||
Follow the Optimizing prefetching guide for stage and link policy. If the
|
||||
contract also requires changing cache placement, Suspense, or loading UI,
|
||||
follow the static-shell documentation used by
|
||||
`next-cache-components-optimizer`. Preserve the existing freshness and
|
||||
authorization behavior. Change only what the selected contract requires.
|
||||
|
||||
When reusable UI should wait for navigation, follow the
|
||||
[`unstable_navigation()`](https://nextjs.org/docs/app/api-reference/functions/navigation)
|
||||
reference, including its comparison with `connection()`. Then verify both
|
||||
properties independently. The `instant()` assertion proves that the UI is
|
||||
absent from the prefetch; it does not prove that the underlying work stayed
|
||||
reusable. Verify that reusable work remains cached below the stage boundary.
|
||||
|
||||
When the contract needs an explicit runtime stage, follow the API references
|
||||
for [`unstable_prefetch()`](https://nextjs.org/docs/app/api-reference/functions/prefetch)
|
||||
and
|
||||
[`unstable_navigation()`](https://nextjs.org/docs/app/api-reference/functions/navigation).
|
||||
|
||||
Work one accepted navigation to GREEN before moving to another. Do not create
|
||||
an app-wide Link or cache abstraction from a single case.
|
||||
|
||||
If the optimization adds or expands a cache boundary, follow
|
||||
[Revalidating](https://nextjs.org/docs/app/getting-started/revalidating).
|
||||
When a writer can change that cached data, test the complete lifecycle: populate
|
||||
the cache, perform the mutation, then read the data again and verify the updated
|
||||
value. A passing `instant()` test proves prefetched readiness, not mutation
|
||||
freshness.
|
||||
|
||||
## Verify and ship
|
||||
|
||||
Keep the passing locked test for the real source link as regression coverage.
|
||||
Preserve the loaded page's content, ordering, empty and error states,
|
||||
authorization, freshness, redirects, and direct-load behavior.
|
||||
|
||||
Finally, remove only the optimization and rerun the test:
|
||||
|
||||
- the App Shell stays GREEN;
|
||||
- the selected prefetch contract returns RED.
|
||||
|
||||
Reapply the optimization and require GREEN again. This differential proves the
|
||||
test guards the exact link policy instead of unrelated cached state. Ship only
|
||||
the final positive test.
|
||||
|
||||
Report the verified result using the user-facing format above. Be precise that
|
||||
prefetching is best-effort; the App Shell remains the fallback when it has not
|
||||
completed.
|
||||
|
||||
Treat request counts, transferred bytes, and cache behavior as measurements,
|
||||
not as part of the `instant()` contract. Do not classify requests using private
|
||||
RSC URLs or internal headers such as `next-router-prefetch`. The public testing
|
||||
API verifies the rendered result, not the protocol stage that produced each
|
||||
request.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Cache Components and Partial Prefetching were already adopted.
|
||||
- [ ] The target UI, trigger, freshness, and authorization constraints were
|
||||
resolved from the request and existing application.
|
||||
- [ ] The test clicks the exact source link and verifies the exact destination.
|
||||
- [ ] Each source-link, destination, and trigger contract has its own test.
|
||||
- [ ] The unlocked baseline and locked RED used the same production artifact.
|
||||
- [ ] The App Shell stayed visible throughout the RED/GREEN loop.
|
||||
- [ ] The selected UI is present and navigation-only UI is absent under lock.
|
||||
- [ ] Reusable navigation-only work remains cached below its navigation stage.
|
||||
- [ ] After populating any new cache whose data can be updated, a mutation test
|
||||
confirms the next read returns the expected data.
|
||||
- [ ] Loaded content, freshness, authorization, and direct visits are unchanged.
|
||||
- [ ] Removing only the optimization returns the contract to RED.
|
||||
- [ ] The final positive `instant()` regression test ships.
|
||||
|
||||
## Handoff
|
||||
|
||||
Finish every navigation named in the request. If Cache Components or Partial
|
||||
Prefetching are not adopted, use `next-cache-components-adoption` or
|
||||
`next-partial-prefetching-adoption`, then return to this workflow. If the App
|
||||
Shell cannot commit under `instant()`, use `next-cache-components-optimizer`
|
||||
and resume the selected navigation afterward. Do not leave a build or test for
|
||||
the user to monitor.
|
||||
|
||||
Report additional optimization candidates without changing them unless they
|
||||
are already in scope. Do not broaden a selected per-link policy into an app-wide
|
||||
Link or cache abstraction.
|
||||
|
||||
## Files
|
||||
|
||||
- `rig-template.md`: production build, test context, navigation contract, and
|
||||
unattended loop discovery for projects without an existing
|
||||
`instant-nav.rig.md`.
|
||||
- `reference/red-test-robustness.md`: trustworthy RED and differential checks.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Optimizing prefetching](https://nextjs.org/docs/app/guides/optimizing-prefetching)
|
||||
- [Instant navigation](https://nextjs.org/docs/app/guides/instant-navigation)
|
||||
- [Caching](https://nextjs.org/docs/app/getting-started/caching)
|
||||
- [Prefetching](https://nextjs.org/docs/app/guides/prefetching)
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Trustworthy RED and differential
|
||||
|
||||
Change the implementation only after the RED proves a genuine prefetch-contract
|
||||
gap for one exact link. A failing assertion alone is not enough.
|
||||
|
||||
## Verification gate
|
||||
|
||||
Require this sequence on the same production artifact and as the same test
|
||||
user:
|
||||
|
||||
1. **Unlocked:** click the exact source link; exact pathname/query, shell, and
|
||||
expected URL-derived target content all render.
|
||||
2. **Locked diagnostic:** click the same link; destination URL changes,
|
||||
`SHELL_MARKER` is visible, and the current result differs from the selected
|
||||
target/navigation-only contract.
|
||||
3. **After release:** every marker selected from the loaded page is visible.
|
||||
|
||||
Only then restore the selected contract assertions and change the
|
||||
implementation.
|
||||
|
||||
## What each signal rules out
|
||||
|
||||
- **Destination URL wait** rules out matching the source page or clicking a
|
||||
nested element that never navigates. Match relevant search params too, not
|
||||
only `pathname`.
|
||||
- **Shell visible under lock** proves the route already has an App Shell and is
|
||||
not wholly blocking.
|
||||
- **Target visible unlocked** rules out auth, flags, empty data, typoed
|
||||
selectors, and redirects hiding the region.
|
||||
- **Target absent locked, visible after release** proves it is outside the
|
||||
prefetched result rather than absent altogether.
|
||||
|
||||
## The trigger is part of the test
|
||||
|
||||
Do not replace the real link with `router.push`, `page.goto`, or another link
|
||||
to the same URL. The testing lock reenacts the clicked link's fetch strategy.
|
||||
It intentionally restricts a default Partial Prefetching link to the App Shell
|
||||
even if a sibling `prefetch={true}` link already warmed a concrete-param cache
|
||||
entry.
|
||||
|
||||
For an intent policy, hover or focus the exact link inside `instant()` before
|
||||
clicking. Click without a fixed delay and assert the prefetched UI after the
|
||||
destination URL changes. Do not warm outside the scope or inspect private
|
||||
request headers to decide when the prefetch is ready. For viewport policy, do
|
||||
not hover: the test should prove the link's declared eager strategy.
|
||||
|
||||
Reusing a browser worker does not require aggregating several contracts into
|
||||
one test. Separate tests prevent navigation cache and interaction state from
|
||||
leaking between routes, and their failures identify the exact contract that
|
||||
regressed.
|
||||
|
||||
## Common false REDs
|
||||
|
||||
- The target is URL-specific but the test user's record/query returns empty.
|
||||
- The target is behind a flag, role, plan, locale, or experiment absent in CI.
|
||||
- Auth redirects to a page where the shell selector happens to exist.
|
||||
- A shared selector matches the source before the destination commits.
|
||||
- The test clicks a default link while the code change modified a different
|
||||
link to the same URL.
|
||||
- Session-only UI was assumed to need a per-link prefetch without proving the
|
||||
default/per-link exact-link differential.
|
||||
- The pathname matches but the query or parameter-derived content is wrong.
|
||||
- The production build does not have the testing API exposed.
|
||||
- A remote test ran against a stale deployment.
|
||||
- A local start failed with `EADDRINUSE` and the test hit the previous build.
|
||||
|
||||
## Contract already GREEN
|
||||
|
||||
Run the locked diagnostic against the exact link as it exists. If the shell and
|
||||
target are visible, navigation-only markers are absent, and the lock is
|
||||
engaged, the current link already meets the contract. It is not an optimizer
|
||||
candidate, even if setting `prefetch={false}` would make it RED. Never add that
|
||||
prop as a test control; the optimizer compares automatic prefetching with no
|
||||
prop against `prefetch={true}`.
|
||||
|
||||
## Lock engagement
|
||||
|
||||
`experimental.exposeTestingApiInProductionBuild` must be true on the measured
|
||||
artifact. A marker that is absent under lock and visible after release is the
|
||||
in-band proof that the lock engaged. A vacuous no-op cannot satisfy both
|
||||
halves.
|
||||
|
||||
Never enable this flag for real production traffic. For a remote rig, verify
|
||||
the deployed commit before trusting RED or GREEN.
|
||||
|
||||
## What `instant()` can verify
|
||||
|
||||
See: [`instant()`](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests).
|
||||
|
||||
The public helper accepts a page, callback, and optional `baseURL`:
|
||||
|
||||
```ts
|
||||
instant(page, callback, { baseURL? })
|
||||
```
|
||||
|
||||
It does not accept a requested stage such as `shell` or `max`. A failed test
|
||||
also does not identify the data read where the prefetch stopped. Compare the
|
||||
same exact link before and after the optimization:
|
||||
|
||||
- existing exact-link policy -> current committed UI;
|
||||
- accepted policy change -> the selected prefetch stages may commit.
|
||||
|
||||
If the optimization does not change the locked result, inspect `params`,
|
||||
`searchParams`, Suspense boundaries, and cache directives using the
|
||||
[Optimizing prefetching guide](https://nextjs.org/docs/app/guides/optimizing-prefetching).
|
||||
Do not compensate with timing.
|
||||
|
||||
`instant()` does not expose a public request classifier for App Shell,
|
||||
per-link, and navigation requests. Internal request headers and segment URLs
|
||||
can change between Next.js versions, so do not use them as product regression
|
||||
assertions. Use `instant()` for the visible contract and record network or
|
||||
platform cost data separately when the task calls for it.
|
||||
|
||||
## Differential
|
||||
|
||||
After the test passes:
|
||||
|
||||
1. Record the build/commit under test.
|
||||
2. Remove only the stage, per-link prefetch trigger, and cache boundary
|
||||
introduced for the contract.
|
||||
3. Rebuild and rerun. The shell should stay available, but the selected
|
||||
prefetch contract should fail.
|
||||
4. Reapply the optimization, rebuild, and rerun. The complete contract should
|
||||
pass again.
|
||||
5. Confirm the unlocked loaded page is identical in both versions.
|
||||
|
||||
If removing the optimization also removes the shell, the change mixed Cache
|
||||
Components work into this loop. Split it. If the contract remains GREEN,
|
||||
another policy still controls the clicked link or the changed boundary does
|
||||
not own the asserted content; the test does not guard the intended
|
||||
optimization. Do not replace the unchanged automatic policy with
|
||||
`prefetch={false}` to force this step RED.
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
# Production `instant()` rig
|
||||
|
||||
The optimizer suite needs a production build that exposes the Next.js
|
||||
testing API, a stable URL for that build, and a Playwright command that can
|
||||
drive the selected source Links. Discover this setup once, record it in
|
||||
`instant-nav.rig.md`, and reuse it across optimization runs.
|
||||
|
||||
Read an existing `instant-nav.rig.md` before creating one. Inspect the
|
||||
repository before asking the user:
|
||||
|
||||
- `package.json` scripts for build, start, and end-to-end tests
|
||||
- `playwright.config.*` for `baseURL`, `webServer`, projects, and authentication
|
||||
- `next.config.*` for existing `experimental` options
|
||||
- CI, preview deployment, container, and hosting configuration
|
||||
- test helpers for login, `storageState`, fixtures, flags, and seeded data
|
||||
|
||||
Ask only for details the repository cannot answer, such as unavailable
|
||||
credentials or which remote environment may expose the testing API.
|
||||
|
||||
## What the rig must define
|
||||
|
||||
### Production build and server
|
||||
|
||||
Use `next build` followed by `next start`, or a remote artifact produced by the
|
||||
same production build. Automatic prefetching does not run in `next dev`, so a
|
||||
development server cannot verify the prefetched result.
|
||||
|
||||
Record separate build and start commands. For a local rig, record the port,
|
||||
stop any previous server before starting, fail on `EADDRINUSE`, and confirm the
|
||||
new process owns the port before running Playwright. `next start` can fork a
|
||||
`next-server` child, so the launcher process ID may not own the port. Start the
|
||||
server in a process group that the rig can stop as a unit, or discover and stop
|
||||
the process listening on the recorded port before the next build.
|
||||
|
||||
### Testing API
|
||||
|
||||
An `instant()` test against a production build requires
|
||||
`experimental.exposeTestingApiInProductionBuild`. Gate it so real production
|
||||
builds do not expose the API:
|
||||
|
||||
```ts filename="next.config.ts" highlight={3,8-10}
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const exposeTestingApi = process.env.EXPOSE_TESTING_API === '1'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
cacheComponents: true,
|
||||
experimental: {
|
||||
exposeTestingApiInProductionBuild: exposeTestingApi,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
```
|
||||
|
||||
Merge the option into an existing `experimental` object instead of replacing
|
||||
the project's other experimental options.
|
||||
|
||||
Set the condition while running `next build`. Setting it only for `next start`
|
||||
is too late because the testing API is compiled into the production artifact.
|
||||
When the artifact was built without it, Next.js does not activate the
|
||||
navigation lock, so the test cannot distinguish prefetched UI from streamed
|
||||
dynamic content. Rebuild with the condition enabled before interpreting the
|
||||
results. Use the project's existing environment naming when it already
|
||||
distinguishes test, staging, preview, and production builds.
|
||||
|
||||
### Test command and base URL
|
||||
|
||||
Record the exact Playwright command and how it receives the measured build's
|
||||
URL. Reuse the project's package manager, Playwright configuration, projects,
|
||||
and reporters. The suite must import `instant()` from `@next/playwright`. If
|
||||
the dependencies are absent, install `@next/playwright` on the same release
|
||||
line as the project's `next`, alongside `@playwright/test`.
|
||||
|
||||
For a local rig, a typical sequence is:
|
||||
|
||||
```bash filename="Terminal"
|
||||
EXPOSE_TESTING_API=1 pnpm build
|
||||
pnpm start --port 3000
|
||||
BASE_URL=http://localhost:3000 pnpm playwright test tests/prefetch-optimization.spec.ts
|
||||
```
|
||||
|
||||
Adapt the script names and port to the project. Keep the production server
|
||||
running while the test command executes. Follow the public
|
||||
[client-navigation test](https://nextjs.org/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests): load the source route, confirm the real
|
||||
Link is visible, then enter `instant()`, click, wait for the destination URL,
|
||||
and assert the prefetched UI.
|
||||
|
||||
### Test context
|
||||
|
||||
Record the state required to reach the selected source Link and destination UI:
|
||||
|
||||
- Use `public; no authentication` when the navigation is public.
|
||||
- Otherwise record the test account and login mechanism, including a fixture,
|
||||
`storageState`, API login, or seeded session.
|
||||
- Record flags, plan, role, locale, seeded data, and other state that can change
|
||||
which UI the test sees.
|
||||
|
||||
A test user is not required. The field exists to make authenticated and
|
||||
state-dependent tests reproducible when the app needs one.
|
||||
|
||||
### Drift
|
||||
|
||||
List differences between the state used to choose the navigation contract and
|
||||
the state used by Playwright. Feature flags, permissions, empty test data, and
|
||||
locale differences can make an assertion fail because the target is
|
||||
unreachable, not because Partial Prefetching removed it. Write `none known`
|
||||
only after checking the test context.
|
||||
|
||||
### Iteration loop
|
||||
|
||||
Record the complete loop the agent can repeat without rediscovering commands:
|
||||
|
||||
- Local: build with the testing API, start the new artifact, run the focused
|
||||
suite, stop the server, edit, and repeat.
|
||||
- Remote: push, wait for the measured artifact, verify it matches `HEAD`, run
|
||||
the focused suite against its URL, edit, and repeat.
|
||||
|
||||
Note any step the agent cannot perform without the user, including deployment
|
||||
approval, protected branches, secrets, or multi-factor authentication.
|
||||
|
||||
### Artifact liveness
|
||||
|
||||
For a remote rig, record how the test proves the deployment matches `HEAD`.
|
||||
Prefer an endpoint or response header that exposes the deployed commit SHA. If
|
||||
the app has neither, use the deployment provider's API to select the artifact
|
||||
whose commit SHA matches `HEAD`.
|
||||
|
||||
A freshly completed local `build` followed by `start` does not need a SHA
|
||||
probe. Record `n/a; local build and start`.
|
||||
|
||||
### Walls
|
||||
|
||||
Record build and run obstacles with their working resolution, such as required
|
||||
environment variables, server-only imports that fail during prerendering,
|
||||
unavailable credentials, or a process that keeps reclaiming the test port.
|
||||
Reuse these notes on the next iteration.
|
||||
|
||||
## Write `instant-nav.rig.md`
|
||||
|
||||
Place this file at the repository root or next to the end-to-end configuration:
|
||||
|
||||
```md
|
||||
# instant-nav rig: <project>
|
||||
|
||||
- BUILD: <commands or platform that builds and serves the measured production artifact>
|
||||
- EXPOSE: <condition that enables exposeTestingApiInProductionBuild during build>
|
||||
- RUN: <focused Playwright command and how it receives BASE_URL>
|
||||
- TEST USER: <public/no auth, or account and login>; state: <flags, role, data, locale>
|
||||
- DRIFT: <differences that could change the asserted UI>
|
||||
- CONTRACTS: <source Link, destination, prefetched UI, navigation-only UI, and viewport or intent trigger>
|
||||
- LOOP: <local build → start → test, or push → deploy → test>; agent limits: <...>
|
||||
- LIVENESS: <deployed SHA check, or n/a for a local build and start>
|
||||
- WALLS: <project-specific obstacles and their resolutions>
|
||||
```
|
||||
|
||||
`CONTRACTS` may list more than one focused navigation, but each contract needs
|
||||
its own test. Every field needs a concrete value. `n/a` is valid only with a
|
||||
reason, such as `TEST USER: public; no authentication` or
|
||||
`LIVENESS: n/a; local build and start`.
|
||||
|
||||
## Check the rig before writing the baseline
|
||||
|
||||
Before recording the prefetch contract:
|
||||
|
||||
1. Build with the testing API condition enabled.
|
||||
2. Start or locate that exact artifact and confirm the base URL responds.
|
||||
3. Run one focused `instant()` smoke test through a real `<Link>` navigation.
|
||||
4. Confirm the test can reach its source Link and eventual destination UI in
|
||||
the recorded test context.
|
||||
|
||||
Fix the rig before interpreting an optimizer failure. A missing testing API,
|
||||
stale deployment, unreachable target, or wrong test state is an environment
|
||||
failure rather than evidence that the optimization changed the prefetched UI.
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "64b65839c7f8096a534c872c03d688a2b2491c8f",
|
||||
"commit": "09ad58f0d58decc9d30799ca83374ff2604ef16b",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-09-09T16:00:00Z"
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
name: ppt-master
|
||||
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
|
||||
metadata:
|
||||
version: "6.3.1"
|
||||
version: "6.3.2"
|
||||
copyright: "Copyright (c) 2025-2026 Hugo He"
|
||||
license: "MIT"
|
||||
official_repository: "https://github.com/hugohe3/ppt-master"
|
||||
|
||||
@@ -234,7 +234,7 @@ Animations anchor on unique top-level `<g id>` content groups (`cover-title`, `c
|
||||
|
||||
**Hard rule — existing groups are not custom-animation intent**: during the custom stage derive one group per logical motion unit from claims, comparisons, sequence, causality, and narration — splitting coarse wrappers and merging fragments without changing appearance, never to hit a count — and run `list-groups` only after that rewrite.
|
||||
|
||||
**Chrome stays static by default**: a `data-pptx-layer` group is structural and never animates, and a full-canvas background rect becomes the slide background, which cannot animate either. A static role/placeholder marker or a chrome-name id (background, header/footer, decor, watermark, page number, nav, logo, rule) keeps a group out of automatic animation and the scaffold; a sidecar entry that names it animates it — a background field panning (§4.1), a cover band sliding in — judged per page, never by category. Wrap logical sections in `<g id>` ([`shared-standards-core.md`](./shared-standards-core.md) §4.3); a root with no top-level group falls back to a bounded primitive set ([`svg-pipeline.md`](../scripts/docs/svg-pipeline.md)).
|
||||
**Chrome stays static by default**: a `data-pptx-layer` group is structural and never animates, and a full-canvas background rect becomes the slide background, which cannot animate either. A static role/placeholder marker or a chrome-name id (background, header/footer, decor, watermark, page number, nav, logo, or a bare `rule` / decorative-line `hairline-rule`; a content group such as `commit-rule` is not chrome) keeps a group out of automatic animation and the scaffold; a sidecar entry that names it animates it — a background field panning (§4.1), a cover band sliding in — judged per page, never by category. Wrap logical sections in `<g id>` ([`shared-standards-core.md`](./shared-standards-core.md) §4.3); a root with no top-level group falls back to a bounded primitive set ([`svg-pipeline.md`](../scripts/docs/svg-pipeline.md)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Always-loaded Executor authority for flat SVG page authoring, shared by Default
|
||||
| A page's contour reaches beyond rectangle, rounded rectangle, circle, ellipse, and line — an inflected carrier (snipped or one-sided rounded rectangle, plaque, bevel, polygon, pie / arc / donut, frame, corner, folded corner, trapezoid, parallelogram) as much as a relationship symbol (block arrow, chevron, callout, flowchart node, banner, star, bracket, connector) — or needs a Boolean / freeform decision | [`native-shape-authoring.md`](./native-shape-authoring.md), read completely (the preset vocabulary is already resident) |
|
||||
| A page's visual job reaches beyond the everyday block below — faux glass, constructed styles (hand-drawn, ink, riso, pixel, halftone, paper-cut, facets, gradient ribbon), gradient stroke, text picture/texture fill, gauge / sunburst / explicit arc geometry, freeform curves, transforms beyond rotate, or an unsupported effect needing a native-safe alternative | [`svg-effects.md`](./svg-effects.md) |
|
||||
| Any image | [`executor-image.md`](./executor-image.md) + [`image-layout-spec.md`](./image-layout-spec.md) + [`image-layout-patterns.md`](./image-layout-patterns.md) + [`svg-image-embedding.md`](./svg-image-embedding.md) |
|
||||
| Any nontrivial mathematical expression | [`native-formula.md`](./native-formula.md) |
|
||||
| Structural notation — a fraction, radical, n-ary operator, matrix, or delimiter pair (flat arithmetic, percentages, and `O(n log n)`-style tokens stay ordinary text) | [`native-formula.md`](./native-formula.md) |
|
||||
| Any external or same-deck click hyperlink | [`native-hyperlinks.md`](./native-hyperlinks.md) |
|
||||
| Any placed image is `Status: Sourced` or its filename has an `image_sources.json` record | [`executor-web-image.md`](./executor-web-image.md), after `executor-image.md` |
|
||||
| Effective Speaker Notes outcome is enabled after all SVG pages pass | [`executor-notes.md`](./executor-notes.md) |
|
||||
@@ -71,7 +71,7 @@ Read this before §1: the expression vocabulary every page draws from — what e
|
||||
| Elevated primary object | The one card, image, or CTA that sits above the page | `softShadow` at resting opacity on that object; peers stay flat |
|
||||
| Duotone or brand-wash image | A photo that must join the deck palette instead of fighting it | Wash gradient over the picture, or a prepared duotone derivative ([`svg-effects.md`](./svg-effects.md) §6.5 / §6.12) |
|
||||
|
||||
**Reference — layout structures (starting points; proportion follows information weight)**: 16:9 values for 1280×720 — safe area 1200×640 with 40 px margins; title band ≈ 100 px, content field ≈ 500 px, footer ≈ 40 px.
|
||||
**Reference — layout structures (starting points; proportion follows information weight)**: 16:9 values for 1280×720 — safe area 1200×640 with 40 px margins; title band ≈ 100 px, content field ≈ 500 px, footer ≈ 40 px. 4:3 values for 1024×768 — safe area 944×688 with 40 px margins; title band ≈ 90 px, content field ≈ 540 px, footer ≈ 40 px; the field is 256 px narrower than 16:9, so a text column beside an image needs ≥ 520 px or the page stacks top/bottom. A4 portrait values for 1240×1754 (`a4`, print) — 80 px margins, a running masthead band ≈ 100 px, body field 1080×1490, footer band ≈ 60 px; regions stack, an evidence margin of ≈ 330 px beside a ≥ 700 px argument column reads as a document, and a chart takes the full measure.
|
||||
|
||||
| Content relationship | Useful starting structure | Starting geometry |
|
||||
|---|---|---|
|
||||
@@ -164,7 +164,7 @@ Repeating symmetric card grids without a page job is the failure mode these stru
|
||||
| Activation | Only an explicit user motion instruction, an enabled Custom Animations outcome, or an existing `animations.json`; a §IX Motion suggestion alone does not activate it |
|
||||
| Missing state | A deck exported without both states cannot gain the motion by a flag |
|
||||
| Extra page | Adding a page is a §IX roster change and returns to Strategist |
|
||||
| Endpoint id | Fix the first endpoint's direct-root `<g id>` when that page is drawn and record the intended partner id in `notes/total.md`; the next page reuses it, so no endpoint is renamed after both pages exist (a rename reruns the gate) |
|
||||
| Endpoint id | Fix the first endpoint's direct-root `<g id>` when that page is drawn and record the intended partner id with `workflow_log.py` (never in `notes/total.md`, which is read verbatim by TTS); the next page reuses it, so no endpoint is renamed after both pages exist (a rename reruns the gate) |
|
||||
|
||||
---
|
||||
|
||||
@@ -280,7 +280,7 @@ Mechanical repetition comes from reusing one carrier and topology without a page
|
||||
**Checkpoints**:
|
||||
|
||||
- **Phased generation** (recommended):
|
||||
1. **Visual Construction Phase**: generate all pages sequentially, applying every triggered branch while drawing. **MUST embed one object-scoped plot-area marker** per §IX-named or Quick-promoted value-driven chart object ([`executor-chart.md`](./executor-chart.md) §2); calibration follows in [`verify-charts`](../workflows/stages/verify-charts.md). Write every `<object-key>=yes` native marker plus JSON metadata atomically ([`native-data-interface.md`](./native-data-interface.md) §2) and stamp its baseline before the page's gate — `python3 ${SKILL_DIR}/scripts/stamp_native_fallbacks.py <project_path>/svg_output/<page>.svg --write`, rerun after any visible edit inside the marker group. **Reach for native presets** per §3.0 as you draw, decided by the object's intent, never by scanning finished paths; several presets for one page go through one `preset_shape_svg.py render-batch --input -` round (gradient/pattern paint stays ordinary SVG; a justified §6.4 shadow/glow stays on the helper-authored shape).
|
||||
1. **Visual Construction Phase**: generate all pages sequentially, applying every triggered branch while drawing. **MUST embed one object-scoped plot-area marker** per §IX-named or Quick-promoted value-driven chart object ([`executor-chart.md`](./executor-chart.md) §2); calibration follows in [`verify-charts`](../workflows/stages/verify-charts.md). Write every `<object-key>=yes` native marker plus JSON metadata atomically ([`native-data-interface.md`](./native-data-interface.md) §2) and stamp its baseline before the page's gate — `python3 ${SKILL_DIR}/scripts/stamp_native_fallbacks.py <project_path>/svg_output/<page>.svg --write`, rerun after any visible edit inside the marker group, a `compact_svg_styles.py --inplace` rewrite included. **Reach for native presets** per §3.0 as you draw, decided by the object's intent, never by scanning finished paths; several presets for one page go through one `preset_shape_svg.py render-batch --input -` round (gradient/pattern paint stays ordinary SVG; a justified §6.4 shadow/glow stays on the helper-authored shape).
|
||||
2. **Quality gates**: the gate points and their commands are the route's — [`generate-pptx.md`](../workflows/generate-pptx.md) Step 6 or [`quick-generate.md`](../workflows/profiles/quick-generate.md) §3–4. The repair discipline at every gate is the same: run the checker unfiltered, review the complete issue set, fix every error plus the selected warnings in one consolidated pass, verify once. Never check between individual fixes, never `cat` a passing report, never defer errors past `finalize_svg.py` (it rewrites SVG and masks violations). Every `warning` is advisory.
|
||||
3. **Logic Construction Phase (conditional)**: after the gates pass, generate speaker notes for narrative continuity only when the effective Speaker Notes outcome is enabled.
|
||||
|
||||
@@ -293,7 +293,7 @@ Mechanical repetition comes from reusing one carrier and topology without a page
|
||||
| A primary image reduced to a minor frame | Restore its planned share |
|
||||
| Unrelated page jobs collapsing into one neutral construction | Vary the construction by job |
|
||||
|
||||
**Absence needs a reason.** Each of these receipt facts needs one written line — what carries that job instead, and why that serves the reader better: a deck-wide `Presets: (none)`; `inline emphasis 0`, `gradients 0`, or `filters 0` on the `Effects:` line; fewer pages carrying a preset or connector than pages whose relationship line (Default: §IX `Relationships`; Quick: the transient relationship statement) names `order` / `link` / `parent` / `membership`; a `Presets:` line naming no carrier-and-field contour. Answer per family the presets serve, not for arrows alone:
|
||||
**Absence needs a reason.** Each of these receipt facts needs one written line — what carries that job instead, and why that serves the reader better: a deck-wide `Presets: (none)`; `inline emphasis 0`, `gradients 0`, or `filters 0` on the `Effects:` line; fewer pages carrying a preset or connector than pages whose relationship line (Default: §IX `Relationships`; Quick: the transient relationship statement) names `order` / `link` / `parent` / `membership`; a `Presets:` line naming no carrier-and-field contour; `icons: 0` while §VI (Quick: the prepared pool) registered an icon pool. Answer per family the presets serve, not for arrows alone:
|
||||
|
||||
| Family | Members |
|
||||
|---|---|
|
||||
|
||||
@@ -69,6 +69,8 @@ After each page containing verified charts: `rg -n "chart-plot-area" <project_pa
|
||||
|
||||
---
|
||||
|
||||
**ChartEx** (`waterfall`, `funnel`, `histogram`, `pareto`, `treemap`, `sunburst`, `box_whisker`) takes no `axes`, `plot_area`, or `data_labels`; values travel only as companion text — one `notes[]` entry per drawn value label, no tick labels — or pick a classic type.
|
||||
|
||||
## 3. Verification Handoff
|
||||
|
||||
Coordinate calibration is a conditional post-generation stage. After all SVG pages exist, run [`verify-charts`](../workflows/stages/verify-charts.md) whenever the active profile declares at least one page with value-driven chart geometry: Default enumerates Design Spec §IX (with the stage's legacy §VII fallback); Quick cross-checks still-active page decisions one-for-one against plot-area markers. Do not run `svg_position_calculator.py` during the initial draft; the stage calibrates completed geometry against the declared plot area, repairs genuine mismatches, and returns to the profile's checker order.
|
||||
@@ -126,7 +126,7 @@ A sheet generates compatible transparent **illustration**, **illustrated-icon**,
|
||||
**Sheet prompt convention** — one `page_role: local` item with `image_size` from final placement; spot sheets `text_policy: none`, lettering sheets `embedded`:
|
||||
|
||||
- **Grid**: derive `aspect_ratio` and `--grid` from the target shape, not a universal square grid. State an invisible logical **R×C grid** and the cell shape (compact square object, tall portrait, wide vignette, wide lettering mark); center each element in about 65% of its cell and keep at least 10% key-only margin on every side — tips, steam, particles, and effects included — so `slice_images.py --strict-alpha` never meets content on a cell edge. Never draw cells, panels, dividers, borders, frames, or alternate gutter colors; never shrink every subject into a square sticker.
|
||||
- **Key**: one flat chroma key across the sheet — pure `#00FF00`, `#0000FF`, or `#FF0000`, chosen so its hue is absent from every element and effect (a pure key despills and recovers soft alpha; an opaque element of the key's hue keeps its color, but thin dark strokes of that hue still fringe) — stated as exact HEX before the palette and the subjects (deck colors appear only inside elements), unchanged in every gutter, free of reflections or spill; grain, halftone, and vignette stay inside elements. The key is technical, not deck palette. Every pixel that is not an element is the key itself — no white card, panel, paper, mat, or frame under an element and no second tint of the key; state the key rule first in the prompt and repeat it last, because a model told only "no borders" still paints each element on a white card.
|
||||
- **Key**: one flat chroma key across the sheet — pure `#00FF00`, `#0000FF`, or `#FF0000`, chosen so its hue is absent from every element and effect (a pure key despills and recovers soft alpha; an opaque element of the key's hue keeps its color, but thin dark strokes of that hue still fringe; on a dark deck the despilled fringe takes the key's complementary hue — white chalk keyed on pure blue leaves a yellow-green halo on slate — so plan two runs for them: slice with the pure key first, then rerun with the measured ground colour `--strict-alpha` names, which skips despill) — stated as exact HEX before the palette and the subjects (deck colors appear only inside elements), unchanged in every gutter, free of reflections or spill; grain, halftone, and vignette stay inside elements. The key is technical, not deck palette. Every pixel that is not an element is the key itself — no white card, panel, paper, mat, or frame under an element and no second tint of the key; state the key rule first in the prompt and repeat it last, because a model told only "no borders" still paints each element on a white card.
|
||||
- **Identity**: shared `deck_rendering` + `color_scheme`.
|
||||
- **Illustration / illustrated-icon sheet**: name each element and its page or reuse job; for an icon, the compact cue that must survive at placement size; the §5.3 `none` cue.
|
||||
- **Lettering sheet**: exactly one named stable string per cell as the only text, quoted literally; the group's letterform character and treatment, then role, placement/background relationship, relative weight, and energy under §5.3's controlled-authorship default; artistry glyph-bound (silhouette, stroke structure, material, texture, depth, contour-bound light). No topic motifs, scene fragments, icons, detached ribbons, or particles unless the approved treatment is a lettering-plus-illustration lockup; key-only padding, no scene, unrelated copy, labels, watermark, or mockup surface. A single mark on a wide sheet (3:2 or wider) tends to come back with a faded ghost copy of itself above or below the real mark — give a lone mark a narrow band (about 4:1) that leaves no room for a second line, and inspect every lettering slice for a ghost band, not only for the characters.
|
||||
@@ -148,21 +148,24 @@ Shape families that cannot share a roomy grid take separate sheets; coherence co
|
||||
|
||||
| Row | Content |
|
||||
|---|---|
|
||||
| **Sheet row** | `Acquire Via: ai`, `Type: Illustration Sheet`, named as the slice source with intent, cell shape, and purpose (`Reference: reusable title/corner illustration family`, `illustrated-icon set: cues = ...`, or `decorative lettering set: exact strings = ...`). Step 5 generates it; it is never placed; Image_Generator resolves its aspect ratio, grid, and slice command. Its manifest item carries `slice_grid` and `slice_names` — the comma-separated basenames are the complete required output set |
|
||||
| **Sheet row** | `Acquire Via: ai`, `Type: Illustration Sheet` (the §VIII column; the manifest item omits `type`), named as the slice source with intent, cell shape, and purpose (`Reference: reusable title/corner illustration family`, `illustrated-icon set: cues = ...`, or `decorative lettering set: exact strings = ...`). Step 5 generates it; it is never placed; Image_Generator resolves its aspect ratio, grid, and slice command. Its manifest item carries `slice_grid` and `slice_names` — the comma-separated basenames are the complete required output set |
|
||||
| **Element rows** | One per used element, `Acquire Via: slice`, filename matching `--names`, `Reference` naming the parent and cell, listed in the placeable authority normally with `crop=no-crop` (tight slices use fit, not cover-crop), `Type: Illustrated icon` for a compact cue (never an SVG library entry), reusable across pages, each carrying an owner-resolved layout recommendation, dimensions filled after slicing by `analyze_images.py` |
|
||||
|
||||
```bash
|
||||
SHEET_KEY_HEX="#00FF00" # example only; choose a key absent from every element/effect
|
||||
SHEET_KEY_HEX="#00FF00" # the key stated in the prompt; example only, choose a hue absent from every element/effect
|
||||
python3 scripts/slice_images.py <project>/images/illus_sheet.png --grid 2x3 \
|
||||
--names team,product,customer,growth,risk,vision --trim --alpha \
|
||||
--bg "${SHEET_KEY_HEX}" --strict-alpha
|
||||
# Generated sheets arrive through JPEG, so the ground is never exactly the pure
|
||||
# key: the first run usually fails with a measured border line — rerun with the
|
||||
# --bg / --tolerance it names (the measured ground, which skips despill).
|
||||
```
|
||||
|
||||
`--names` count equals `rows*cols`; `--strict-alpha` writes nothing on an incomplete cut. Three quality constraints:
|
||||
|
||||
| Constraint | Rule |
|
||||
|---|---|
|
||||
| **Strict key recovery** | Measure the smallest channel distance from any element pixel to the key first; raise `--tolerance` only enough to absorb measured flat-field drift and keep it below that distance, `--inset` for an isolated outer gutter or for grid lines the model drew between cells (its most frequent use — trim the gutter, never an element), and regenerate or enlarge when an effect reaches an edge. A `1x1` lettering sheet whose glyph touches the outer key border fails the same gate: pad the sheet with a key-colored border (about 10%) before slicing, or regenerate with more margin |
|
||||
| **Strict key recovery** | Measure the smallest channel distance from any element pixel to the key first; raise `--tolerance` only enough to absorb measured flat-field drift and keep it below that distance, `--inset` for an isolated outer gutter or for grid lines the model drew between cells (its most frequent use — trim the gutter, never an element; wide lettering bands take `--inset 0.01,0.03` style H,V values so the horizontal line goes without cutting into the glyphs), and regenerate or enlarge when an effect reaches an edge. A `1x1` lettering sheet whose glyph touches the outer key border fails the same gate: pad the sheet with a key-colored border (about 10%) before slicing, or regenerate with more margin |
|
||||
| **Clean isolated cells** | Fused cells, scene backgrounds, or flourishes crossing a cell make the sheet unusable; re-rolls follow only a strict keying failure or user/preview evidence, never taste |
|
||||
| **Enough source pixels** | Each cell at least 1.5–2× its display size (`1K` small accents, `2K` medium, `4K` large or enlarged) |
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ x = x0 - ax × overflow_x; y = y0 - ay × overflow_y
|
||||
|
||||
### 2.3 Mode selection
|
||||
|
||||
**Reference — narrative intent before geometry**: decide whether the image is the page (hero / full-bleed: image fills the canvas or dominant zone, title floats over a gradient or scrim — covers, dividers, breathing pages), a backdrop (atmosphere: low-contrast image behind text), a coequal block (side-by-side: image and text read together — most content pages), or an accent (small image beside related text, no ratio matching); do not default every image page to side-by-side. For side-by-side, the source ratio shapes the item inside its region (§2–§3), not the page split; on portrait canvases (Xiaohongshu, Story) side columns become narrow, so stacked regions usually serve better.
|
||||
**Reference — narrative intent before geometry**: decide whether the image is the page (hero / full-bleed: image fills the canvas or dominant zone, title floats over a gradient or scrim — covers, dividers, breathing pages), a backdrop (atmosphere: low-contrast image behind text), a coequal block (side-by-side: image and text read together — most content pages), or an accent (small image beside related text, no ratio matching); do not default every image page to side-by-side. For side-by-side, the source ratio shapes the item inside its region (§2–§3), not the page split; on portrait canvases (Xiaohongshu, Story, A4) and on 4:3 (`ppt43`, 256 px narrower than 16:9) side columns become narrow, so stacked regions usually serve better.
|
||||
|
||||
Complete source, evidence, or edge content → contain; region coverage with a focal-safe crop → fill; complete source plus a detail view → one contain placement plus a separately justified crop; irregular or repeated source windows → the selected region math first, then the owning crop/shape reference.
|
||||
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ Both keep the JSON inside the SVG; `native_payloads.json.gz` is for opaque shape
|
||||
**Hard rule — selected-object authoring**: write the marker and JSON for every supported chart and pure text-grid table in the same edit — both are native-ready by default, and an unactivated marker changes no export, so never skip an eligible object. Default reads the key from §IX `Native-ready: <object-key>=yes|no; …`; Quick assigns the same page-local `kebab-case` key before drawing; the key is the marker group `id` and metadata `name`. A catalog `family/key`, §VII row, numeric content, or another ready object never implies eligibility; `=no` entries and incidental microvisuals stay on the fallback route; a legacy bare `Native-ready: yes|no` maps only to the page's sole eligible object. **MUST — atomic authoring**: one object's visible fallback, `data-pptx-replace-with` marker, and single JSON `<metadata>` child are one authoring unit written while the data is in context, never deferred to `verify-charts`, the final gate, or export. Then stamp SVG-first objects:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/stamp_native_fallbacks.py "<svg-file-or-directory>" --write # read-only without --write; skips JSON-first
|
||||
python3 ${SKILL_DIR}/scripts/stamp_native_fallbacks.py "<svg-file-or-directory>" --write # read-only without --write; skips JSON-first
|
||||
```
|
||||
|
||||
The hash is a synchronization receipt, not proof of semantic equivalence; never stamp stale JSON to satisfy validation.
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ python3 ${SKILL_DIR}/scripts/shape_boolean_svg.py render <svg-file> \
|
||||
|---|---|
|
||||
| Mirror/preserve input already owns native-shape metadata | Keep the object and metadata; never reselect its preset |
|
||||
| One exact non-Connector stock contour | Ordinary SVG primitive only when the exporter maps it to that same contour; otherwise `render` and insert the fragment |
|
||||
| A stock `bentConnector*` / `curvedConnector*` contour expresses a bend or curve with no endpoint attachment | `render --object-kind connector`; an unconnected native Connector |
|
||||
| A stock `bentConnector*` / `curvedConnector*` contour expresses a bend or curve with no endpoint attachment | `render --object-kind connector`; an unconnected native Connector with no line-end markers (the fragment accepts none), so an arrowed edge stays a `<line>` with a §1.1 marker |
|
||||
| A straight relationship, divider, or leader | `<line>`, with a §1.1 marker only when direction is meaningful |
|
||||
| A boundary that needs no filled surface | The exact form with `fill="none"` and a visible stroke; content stays an independent sibling |
|
||||
| Two or more native contours form the construction without needing one contour | Independent siblings in one semantic group, composed under §2.1 |
|
||||
|
||||
@@ -139,7 +139,7 @@ python3 skills/ppt-master/scripts/visualization_recall.py validate \
|
||||
<family>/<key> [<family>/<key> ...]
|
||||
```
|
||||
|
||||
**Native-ready boundary**: give every independent data chart and pure text-grid table a unique page-local `kebab-case` key and a `<key>=yes|no` native-ready decision — `yes` by default, `no` only when [`native-data-interface.md`](./native-data-interface.md) §2 cannot express the object; qualitative compositions and incidental microvisuals stay unlisted. Default writes §VII and the `Native-ready` map; Quick keeps the key and decision in context.
|
||||
**Native-ready boundary**: give every independent data chart and pure text-grid table a unique page-local `kebab-case` key and a `<key>=yes|no` native-ready decision — `yes` by default, `no` when [`native-data-interface.md`](./native-data-interface.md) §2 cannot express the object — `yes` presumes the family has a native `type` there (dumbbell, bullet, gantt, heatmap, sankey, and word clouds have none and stay `no`); qualitative compositions and incidental microvisuals stay unlisted. Default writes §VII and the `Native-ready` map; Quick keeps the key and decision in context.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ These forms are needed only when the stated PPT behavior matters:
|
||||
|
||||
**Hard rule — root groups protect body-text layout**: every visible direct root `<g>` except a compact helper-authored preset atom declares positive root-coordinate `data-pptx-bounds="x y width height"` sized as the intended module zone. On flat pages, maximize ordinary zones within canvas/sibling space without overlap; the checker fails root-group overlap beyond `1px`, warns on module text overflow through `5%` and fails above it, and fails any larger root-`viewBox` text overflow. Bounds do not clip or reflow. A native plate, caption, or label laid over a picture belongs inside that picture's root group, so it takes no separate zone and creates no root-group overlap, and a clipped picture's zone is its visible region; shrinking the picture to free a text zone is not the repair. Structured slots, structural-role groups, and a wholly off-canvas Morph endpoint marked `data-pptx-morph-staging="true"` are overlap-exempt only; thresholds and estimator: [`svg-contract.md`](../scripts/docs/svg-contract.md) §4.
|
||||
|
||||
Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Nested implementation groups may remain anonymous, need no bounds, and create no animation step; use them only when internal subunits (icon + title, value + label, repeated rows) are useful to edit — there is no default nesting pattern, depth, or quota. Titles, direct atomic Master/Layout elements, and canvas-level static framing — background images and full-canvas scrim/decoration rectangles — may remain root primitives; on flat pages give such framing a stable `id` plus `data-pptx-role="background"` / `"decoration"` and never add a `<g>` solely to silence an ungrouped-element advisory; a `<g>` that carries the role is still a root group and declares `data-pptx-bounds` — only a root primitive is exempt.
|
||||
Wrap each logical Slide-local body unit in one descriptive top-level `<g id>`; group count follows the page's semantic units, and each group becomes one stable animation target when animation is enabled. Nested implementation groups may remain anonymous, need no bounds, and create no animation step; use them only when internal subunits (icon + title, value + label, repeated rows) are useful to edit — there is no default nesting pattern, depth, or quota. The page title is one top-level `<g id>` of its own (an animation and Morph target like any unit); direct atomic Master/Layout elements and canvas-level static framing — background images and full-canvas scrim/decoration rectangles — may remain root primitives; on flat pages give such framing a stable `id` plus `data-pptx-role="background"` / `"decoration"` and never add a `<g>` solely to silence an ungrouped-element advisory; a `<g>` that carries the role is still a root group and declares `data-pptx-bounds` — only a root primitive is exempt.
|
||||
|
||||
**Structural atoms and slots are excluded automatically.** `data-pptx-layer` and `data-pptx-placeholder` semantics are read first; otherwise explicit `data-pptx-role` values (`background`, `decoration`, `header`, `footer`, `chrome`, `watermark`, `page-number`, `logo`) mark Slide-local static framing (§4.1, [`semantic-svg.md`](semantic-svg.md)). A normal slot group has exactly one direct compatible carrier; several drawing atoms require the explicit composite `object` proxy fallback. Native chart/table carrier groups retain their specialized [`native-data-interface.md`](./native-data-interface.md) contract.
|
||||
|
||||
|
||||
@@ -42,4 +42,4 @@ For specialized or regulated paper-figure subjects keep the prompt depth of [`im
|
||||
|
||||
**Image pattern** (one non-empty value per placed row, required by the checker): preferred expression in ordinary words, not locked geometry (an id from [`image-layout-patterns.md`](./image-layout-patterns.md) only when already known; Executor loads that catalog); Executor adjusts it freely as a Reference while keeping resource identity, must-use status, crop/content, and explicit constraints (layout-only changes need no upstream rewrite). A useful entry names the job the image does for the content or the page's shapes — the catalog is recall, an uncatalogued technique answers a job equally, plain split and full bleed stay valid, and a `no-crop` or supporting row keeps one concise suggestion. Choose narrative intent before dimensions, then derive `Dimensions` / `Ratio` from the intended region of the confirmed canvas (placement geometry itself is Executor's [`image-layout-spec.md`](./image-layout-spec.md)); a cutout, blurred crop, or desaturated copy requires that prepared asset.
|
||||
|
||||
**Crop policy**: `no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content (screenshots, charts, certificates, dense diagrams, logos, product markings are common triggers); otherwise `adaptive` — Executor may use complete display or a focal-safe crop, and the value never commands cropping. Judge `text_policy` per AI row under [`image-generator.md`](./image-generator.md) §5.3; paper figures, schematics, panel comparisons, data-axis graphics, and stable lettering are triggers to reconsider an all-`none` plan. Step 5 dispatches pending `ai` / `slice` rows to Image_Generator and `web` rows to Image_Searcher.
|
||||
**Crop policy**: `no-crop` whenever cropping could remove required pixels, labels, evidence, identity, or edge content (screenshots, charts, certificates, dense diagrams, logos, product markings are common triggers); otherwise `adaptive` — Executor may use complete display or a focal-safe crop, and the value never commands cropping; artwork that will be clipped into a carrier shape (poster frame, device screen, lightbox, window) is `adaptive`, because `no-crop` forbids `clip-path` and nested-`<svg>` crops on every instance. Judge `text_policy` per AI row under [`image-generator.md`](./image-generator.md) §5.3; paper figures, schematics, panel comparisons, data-axis graphics, and stable lettering are triggers to reconsider an all-`none` plan. Step 5 dispatches pending `ai` / `slice` rows to Image_Generator and `web` rows to Image_Searcher.
|
||||
@@ -92,7 +92,7 @@ Seed the six contract fields of [`plan-core.md`](./plan-core.md) §1 as open-pro
|
||||
|
||||
**Hard rule — top-down direction construction**: author three complete, project-fit solution intents from the confirmed contract and source before touching any catalog basis. The three mode/style/rendering indexes are the only basis selectors. Freeze each direction's exact reference ids from the index summaries, read once only the deduplicated union of those detail files, then write the behaviors. Never force safe / shifted / bold archetypes, glob a catalog, read an unselected sibling, or write bespoke prose as an enum value.
|
||||
|
||||
**Direction serialization**: every direction serializes `mode: custom`, `visual_style: custom`, and `image_strategy.rendering: custom`, each with visible non-empty behavior prose. A custom may use catalog material in any way or none; one preset carried unchanged is valid. References record only actual sources, each owning a distinct executable contribution, never a decorative second basis.
|
||||
**Direction serialization**: every direction serializes `mode: custom`, `visual_style: custom`, and `image_strategy.rendering: custom`, each with visible non-empty behavior prose. A custom may use catalog material in any way or none; one preset carried unchanged is valid; a layer the user or an installed template already fixed to a catalog preset locks that preset id instead of `custom`. References record only actual sources, each owning a distinct executable contribution, never a decorative second basis.
|
||||
|
||||
**Three plainly different designs**: the three directions differ as designs *before* any field is written. Whichever components a design requires carry the difference; mode, style, rendering, bases, color, type, and icons are each free to coincide. A different name, note, or reference count alone is no difference, and identical projections are not three solutions. Where authoritative truth fixes components, the open ones carry the difference. Where nothing is open, keep the projections identical and state that boundary.
|
||||
|
||||
|
||||
@@ -147,7 +147,11 @@ material, or texture inside editable glyphs, fill the `<text>` (or a
|
||||
non-positional `<tspan>`) with a registered single-image `<pattern>` marked
|
||||
`data-pptx-text-image-fill="stretch"` or `"tile"`. It exports as a PowerPoint
|
||||
run picture fill (`stretch` `Native-normalized`; `tile` needs visual review),
|
||||
not as a general SVG pattern; the text stays editable.
|
||||
not as a general SVG pattern; the text stays editable. The glyphs are the
|
||||
only thing showing the image, so the fill must read as one value against the
|
||||
ground — a near-uniform texture or material, not a scene, gradient, or
|
||||
anything with its own light and dark regions, which breaks the letterforms
|
||||
into unreadable patches (a 2026-09-10 cover title was lost this way).
|
||||
|
||||
```xml
|
||||
<defs>
|
||||
@@ -251,7 +255,11 @@ alone. A clip disables `meet` frame-fit, so match the box to the source ratio
|
||||
or use `slice`; put a §6.4 filter directly on an unclipped image, and for a
|
||||
clipped one on an exact outer `<g>` whose sole visual child is that image —
|
||||
never both on the same `<image>`. A nested `<svg>` is only the exact
|
||||
single-image crop wrapper the crop parser accepts, not a general viewport.
|
||||
single-image crop wrapper the crop parser accepts, not a general viewport;
|
||||
its required form (outer `preserveAspectRatio="none"` + `overflow="hidden"`
|
||||
+ unit-space `viewBox`, child `<image>` at `0 0 1 1`) is in
|
||||
[`svg-contract.md`](../scripts/docs/svg-contract.md) under "Nested SVG is
|
||||
picture-crop transport".
|
||||
|
||||
| Overlay | Construction | Typical stops / alpha |
|
||||
|---|---|---|
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ Engineering schematic — thin line work on dark blueprint paper, isometric proj
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
`blueprint` — lock it so AI imagery shares the schematic line-drawing aesthetic.
|
||||
`blueprint` — one coherence candidate for AI imagery that shares the schematic line-drawing aesthetic; the resolved rendering decision owns the choice.
|
||||
|
||||
## 6. Illustration propensity
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ Strict Swiss-grid discipline. Modular grid, sharp geometry, aggressive whitespac
|
||||
|
||||
## 5. Paired image-rendering
|
||||
|
||||
`minimalist-swiss` — lock it for AI images so illustrations share the grid-austere aesthetic.
|
||||
`minimalist-swiss` — one coherence candidate for AI images that shares the grid-austere aesthetic; the resolved rendering decision owns the choice.
|
||||
|
||||
## 6. Illustration propensity
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
@@ -2526,6 +2527,20 @@ def create_app(
|
||||
|
||||
@app.before_request
|
||||
def _update_activity():
|
||||
allowed_hosts = {PUBLIC_HOST, 'localhost', '::1', '[::1]'}
|
||||
host = request.headers.get('Host', '').lower()
|
||||
if host.startswith('[') or host.count(':') == 1:
|
||||
host = re.sub(r':\d+$', '', host)
|
||||
if host not in allowed_hosts:
|
||||
return jsonify({'error': 'Forbidden Host header'}), 403
|
||||
origin = request.headers.get('Origin')
|
||||
if origin is not None:
|
||||
try:
|
||||
origin_host = urllib.parse.urlsplit(origin).hostname
|
||||
except ValueError:
|
||||
origin_host = None
|
||||
if origin_host not in allowed_hosts:
|
||||
return jsonify({'error': 'Forbidden Origin header'}), 403
|
||||
app.config['LAST_REQUEST_TIME'] = time.time()
|
||||
|
||||
def _exit_with_lock_release(code: int = 0) -> None:
|
||||
|
||||
@@ -621,6 +621,9 @@ Error: PPTX-to-SVG conversion failed: Invalid DrawingML sRGB color structure
|
||||
|
||||
Convert web pages to Markdown and download images locally by default. Use
|
||||
`--no-images` to retain remote image links without downloading their files.
|
||||
Pages, images, and redirect targets must be public HTTP(S) hosts with valid
|
||||
TLS; `--insecure` skips certificate checks, `--allow-private-hosts` admits
|
||||
intranet and loopback addresses.
|
||||
|
||||
```bash
|
||||
python3 scripts/source_to_md/web_to_md.py https://example.com/article
|
||||
|
||||
@@ -49,6 +49,8 @@ python3 scripts/image_gen.py --list-backends
|
||||
|
||||
Backends are grouped into Core / Extended / Experimental tiers. Run `python3 scripts/image_gen.py --list-backends` for the current list.
|
||||
|
||||
Each backend accepts its own subset of `--aspect_ratio` values; the authority is the `VALID_ASPECT_RATIOS` constant in its `scripts/image_backends/backend_<name>.py`, and `--manifest` rejects an unsupported ratio before any request. Gemini 3.1 image models take `1:1 1:4 1:8 2:3 3:2 3:4 4:1 4:3 4:5 5:4 8:1 9:16 16:9 21:9` (no `3:1`); the `gemini-2.5-flash-image` models take only `1:1 2:3 3:2 3:4 4:3 4:5 5:4 9:16 16:9 21:9` at `1K`.
|
||||
|
||||
Backend selection:
|
||||
|
||||
```bash
|
||||
@@ -187,7 +189,9 @@ sources are rejected rather than reduced to one frame, while a camera
|
||||
multi-picture JPEG (MPO) contributes its primary frame.
|
||||
|
||||
Both input and output are bare filenames directly under `images/`; output must
|
||||
be a new `.png` file. The tool keeps the EXIF-corrected display dimensions,
|
||||
be a new `.png` file, or a new `.jpg` file when the source is opaque (a
|
||||
photograph downscaled with `--fit` keeps its weight in check that way; a
|
||||
source with transparency is refused for `.jpg`). The tool keeps the EXIF-corrected display dimensions,
|
||||
leaves any alpha mask unchanged, and never overwrites the source or an existing
|
||||
derivative. If `images/image_sources.json` contains the source filename, the
|
||||
new record inherits that legal provenance and records `derived_from` plus the
|
||||
|
||||
@@ -24,7 +24,7 @@ python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider co
|
||||
- **Outputs**: one `audio/<stem>.<ext>` per note plus `audio/<stem>.srt` on provider-timed paths (edge, ElevenLabs, MiniMax, timestamp-capable CosyVoice); Qwen and explicit `--cosyvoice-audio-only` write audio only. The flat `audio/` directory is the single active set — no provider subdirectories unless the user asks to keep variants. Stale `audio/manifest.json` and `audio/total.srt` are removed before generation; a successful audio-only run also removes same-stem stale SRT; the manifest (provider/model, formats, voice settings, SHA-256 voice fingerprint — no per-slide inventory, hashes, or keys) is published atomically only after the complete roster succeeds.
|
||||
- **Formats**: PowerPoint-reliable audio is `m4a` (AAC), `mp3`, or `wav`; edge defaults to `mp3`; provider `pcm` / `opus` / `flac` output must be transcoded before embedding.
|
||||
- **Edge SRT**: MP3 and page SRT come from the same `edge-tts` stream using `WordBoundary` timing. Sentence-ending punctuation always closes a cue; text over the 20-visible-character default (`--subtitle-max-chars`) splits at commas, semicolons, or colons, then at the nearest word boundary. Adjacent overlap up to 100 ms moves the later cue start to the previous end; larger overlap fails. Each SRT uses a page-local timeline starting at `00:00:00,000` including leading silence. Default concurrency is three slide pairs (`--concurrency 1` for serial troubleshooting); cloud providers stay serial.
|
||||
- **Provider timing**: MiniMax reads word timing from its synchronous subtitle file; ElevenLabs uses `/with-timestamps` with original-text character alignment; CosyVoice enables HTTP streaming plus `word_timestamp_enabled` and uses the final audio URL and word timing — unsupported model/voice pairs fail without replacing the prior pair unless `--cosyvoice-audio-only` was explicit (model and voice families must match; cloned voices need a supported v3.5/v3/v2 model or a timestamp-supported system voice); Qwen exposes no timing and never gets estimated SRT. Provider-timed paths share punctuation-first, `--subtitle-max-chars`-bounded regrouping, exact-text validation, and rollback-safe pair publication.
|
||||
- **Provider timing**: MiniMax reads word timing from its synchronous subtitle file (a normalized number such as `42` comes back as one row per spoken syllable sharing one original-text span; the adapter merges rows by that span, never by text alone); ElevenLabs uses `/with-timestamps` with original-text character alignment; CosyVoice enables HTTP streaming plus `word_timestamp_enabled` and uses the final audio URL and word timing — unsupported model/voice pairs fail without replacing the prior pair unless `--cosyvoice-audio-only` was explicit (model and voice families must match; cloned voices need a supported v3.5/v3/v2 model or a timestamp-supported system voice); Qwen exposes no timing and never gets estimated SRT. Provider-timed paths share punctuation-first, `--subtitle-max-chars`-bounded regrouping, exact-text validation, and rollback-safe pair publication.
|
||||
|
||||
## `narration_sync.py`
|
||||
|
||||
|
||||
@@ -21,20 +21,20 @@ one child `<metadata type="application/json">` (attribute `data-pptx-json` is re
|
||||
|
||||
Every payload carries that exact `schema`. Native tables are rectangular grids: `columns` for the optional header row, `rows` for body rows (shorter rows padded unless `strict_grid: true`; at most 1000 resolved rows and columns); `column_widths` / `row_heights` are finite non-negative relative weights matching the grid with one positive value (`row_heights` counts the header row when `columns` is present) (a row's top and bottom padding shrink to what its resolved height can hold beside the text line, since PowerPoint would otherwise grow the row past the drawn one); `header_rows` is an integer in range; `strict_grid`, `style.band_row`, and `bold` are JSON booleans. Exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` and kebab-case `cell_styles` selected by `cell_style` (precedence: cell defaults → named style → cell fields; object-valued `padding` merges, everything else replaces; `<style>` and `class` remain forbidden).
|
||||
|
||||
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align` (`l` / `ctr` / `r`; header cells export centred unless `align` is set, so SVG-first parity reports a left- or right-anchored fallback header whose payload leaves `align` unset), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` derives `zh-CN` for CJK and `en-US` otherwise. `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values.
|
||||
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align`. (SVG-first parity compares a cell against the fallback `<text>` with its `<tspan>` lines concatenated, so a wrapped cell keeps a trailing space inside every non-final `<tspan>` or the joined words never match.) Cells also take (`l` / `ctr` / `r`; header cells export centred unless `align` is set, so SVG-first parity reports a left- or right-anchored fallback header whose payload leaves `align` unset), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` derives `zh-CN` for CJK and `en-US` otherwise. `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values.
|
||||
|
||||
`style` accepts `font_family`, `font_size`, `header_font_size`, `band_row`, `padding`, `valign`, `border_color`, `border_width`, `lang`, `table_style_id`, and the table-wide colours `header_fill`, `header_text`, `body_fill`, `body_text`, `band_fill`. Fills are only what the payload resolves: a cell with no `fill` takes `style.header_fill` (header rows) or `style.band_fill` / `style.body_fill` (body rows), and when none of those is set it exports as `noFill` so the slide background shows through — the same as a fallback that draws no cell rect. Text colour never falls to nothing: a cell without `color` takes `defaults.run.color` (plain text is one run, and a `paragraphs` cell whose paragraphs are strings or `text` objects inherits the same way at cell level, so `defaults.run` `bold` / `color` / `font_size` reach both when the cell and `defaults.cell` leave them unset; `paragraphs[].runs` take the run defaults directly), then `style.body_text` / `style.header_text`, then `#1F2937` (`header_text` defaults to `#FFFFFF` only when `header_fill` is set). SVG-first parity reports a body text colour drawn in the fallback that none of those layers would carry, and a first-column colour that neither the cell nor `style.body_text` resolves to.
|
||||
`style` accepts `font_family`, `font_size`, `header_font_size`, `band_row`, `padding`, `valign`, `border_color`, `border_width`, `lang`, `table_style_id`, and the table-wide colours `header_fill`, `header_text`, `body_fill`, `body_text`, `band_fill`. SVG-first parity reads a header band drawn as one rect spanning the header rows as `style.header_fill` unless every header cell carries that same `fill` (and the dominant header text colour as `style.header_text` unless every header cell carries it), so a drawn band is projected at table level rather than repeated per cell. Fills are only what the payload resolves: a cell with no `fill` takes `style.header_fill` (header rows) or `style.band_fill` / `style.body_fill` (body rows), and when none of those is set it exports as `noFill` so the slide background shows through — the same as a fallback that draws no cell rect. Text colour never falls to nothing: a cell without `color` takes `defaults.run.color` (plain text is one run, and a `paragraphs` cell whose paragraphs are strings or `text` objects inherits the same way at cell level, so `defaults.run` `bold` / `color` / `font_size` reach both when the cell and `defaults.cell` leave them unset; `paragraphs[].runs` take the run defaults directly), then `style.body_text` / `style.header_text`, then `#1F2937` (`header_text` defaults to `#FFFFFF` only when `header_fill` is set). SVG-first parity reports a body text colour drawn in the fallback that none of those layers would carry, and a first-column colour that neither the cell nor `style.body_text` resolves to.
|
||||
|
||||
**Hard rule — the table payload is complete**: a payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules — every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry). **Merged cells — canonical rectangular contract only**: positive integer `row_span` / `col_span` on the anchor, every covered cell blank (canonical `{"merge_continuation": true}`), spans inside the grid and non-overlapping; the exporter writes `rowSpan` / `gridSpan` / `hMerge` / `vMerge`. Covered-cell blankness is checked before expanding defaults: accepted authored values are `null`, `""`, and objects containing only optional `text` (absent, `null`, or `""`) and `merge_continuation` (absent or exactly `true`), including `{}`. Authored formatting fields make a covered cell nonblank; inherited defaults do not, so `{"text": ""}` stays blank with `defaults.cell.color`. CamelCase aliases, raw OOXML merge fields, top-level merge lists, nonblank covered cells, invalid spans, and overlaps fail.
|
||||
|
||||
## §2 Chart schemas
|
||||
|
||||
- **Category charts** — `column`, `bar`, `line`, `area`, `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`: `categories` plus `series[].values`. Column, bar, line, and area values may be `null` for a gap (the workbook cell stays blank, the cache omits the point, `dispBlanksAs` is `gap`; at least one value per series is a number) — a fallback that breaks one line into segments is two series of the same colour with `null` outside their segment. Pie-family charts take exactly one series with per-slice colors; `hole_size` is doughnut-only, integer `10..90`, default `75`; no rotation field. Column/bar may set `series[].point_colors` (camelCase `pointColors` is read-compatible; length = values). Bar categories run top-down in payload order — the exporter reverses the category axis and moves the value-axis crossing so the value axis keeps its declared position; `axes.category.reverse: false` restores PowerPoint's bottom-up order (column charts stay left-to-right unless `reverse: true`). Bar/column spacing: root `gap_width` (`0..500`, the empty slot share as a percentage of one bar; PowerPoint default `150`) and clustered `overlap` (`-100..100`, the share of one bar the next series covers, negative for a gap); SVG-first export reads both from the fallback bars inside `plot_area` when the payload omits them, JSON-first uses the defaults.
|
||||
- **Line markers** — `line_style: "lineMarker"` draws a circle at every point in the series colour; `marker_size` (root or per series, SVG px diameter, `2..72` pt after `×0.75`) sizes it. `series[].point_colors` on a line series marks single points: a colour draws that point's marker in that colour, `null` leaves it bare (with `line_style: "line"` only the coloured points carry markers), so an emphasised endpoint is `[null, …, "#C8102E"]`. Area series draw no markers; a line with markers over a fill is a `combo`. `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none. Set `data_labels.number_format` to match the fallback's rendered value labels.
|
||||
- **Line markers** — `line_style: "lineMarker"` draws a circle at every point in the series colour; `marker_size` (root or per series, SVG px diameter, `2..72` pt after `×0.75`) sizes it. `series[].point_colors` on a line series marks single points: a colour draws that point's marker in that colour, `null` leaves it bare (with `line_style: "line"` only the coloured points carry markers), so an emphasised endpoint is `[null, …, "#C8102E"]`. Area series draw no markers; a line with markers over a fill is a `combo`. `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none. Set `data_labels.number_format` to match the fallback (the parity gate reads `0`, `0.0`, `0.00`, `#,##0`, `#,##0.0`, their `%` forms — which scale the value by 100 — and a quoted literal prefix/suffix such as `"$"#,##0` or `0.0"%"`; other codes match only the literal value)'s rendered value labels.
|
||||
- **Combo** — shared `categories` plus `plots[]` (`type: "column" | "line" | "area"`, own `series`, optional `axis: "secondary"`, optionally own `categories` / `category_numeric` when caches genuinely differ, and `series_indices` for imported identity — same-length unique non-negative integers forming one contiguous `0..N-1` range across plots) or typed `series[]` with per-series `type` / `axis` (adjacent compatible series share a plot). Area series may set `fill_opacity` (`0..1`; `fillOpacity` read-compatible); a line plot with `area_fill: true` exports as an area chart; line/area series may set `line_width` in SVG px (`lineWidth` read-compatible); column plots take `gap_width` / `overlap`, line plots `line_style` / `marker_size`. An area series is a fill without an outline; `line_width` adds a same-colour outline around the whole region (baseline included), so a fallback that strokes only the top edge — or draws markers on it — is an area plot under a line plot with the same values. Export layers areas below columns and lines.
|
||||
- **Axes** — classic `axes` is a closed object of `category`, `value`, `secondary_category`, `secondary_value`, each with only `kind` (`text` / `date` / `value`), `position`, `visible`, `label_position` (`next_to` / `none` / `low` / `high`), `number_format`, `minimum`, `maximum`, `major_unit` (value axes), `reverse`, `major_gridlines`, `tick_marks` (`none` default / `out` / `in` / `cross`), tick-label `color` / `font_family` / `font_size` (SVG px) when one axis's labels differ from the chart-wide text style, and on value axes `cross_between` (`between` — points sit between ticks, the default written explicitly because renderers disagree when it is absent — or `mid_category` — the first point sits on the axis edge; SVG-first line/area export reads it from where the fallback line starts inside `plot_area`); single-plot `bar` takes `category` left/right and `value` bottom/top; pie-family rejects `axes`. For single-plot category charts, `kind: "date"` is supported only by `area`. Stock dates follow the separate Stock contract below. `position` is honoured through the axis crossing (`top` / `right` cross at the far category, and a reversed crossing axis swaps the ends). `scatter` / `bubble` use `x` and `y` roles only (`kind: "value"`; `x.position` bottom/top, `y.position` left/right) with the same fields. Logarithmic scales, minor units/gridlines, crossing values, display units, and tick skipping are unsupported.
|
||||
- **XY** — `scatter` and `bubble` use `series[].x` + `series[].y` (`bubble` adds one `series[].size` / `sizes` per point), or `series[].points` as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects.
|
||||
- **ChartEx** — `treemap` / `sunburst` (`values` plus `levels[level][point]` or path-style `categories`; treemap `parent_label_layout: "banner" | "overlapping" | "none"` (default `overlapping`), PowerPoint labels only the top level and leaves), `histogram` (`values`), `pareto` / `waterfall` / `funnel` (`categories` + `values`; `waterfall` accepts `subtotals` / `subtotal_indices`), `boxWhisker` (`series[].values`, optional `series[].categories`). ChartEx writes no `<cx:title>` without a payload title (an empty title shows the series name), emits title/subtitle as companion text boxes, and takes `style.colors` / root `colors` into its color-style part. Non-Microsoft renderers show a limited subset.
|
||||
- **ChartEx** — `treemap` / `sunburst` (`values` plus `levels[level][point]` or path-style `categories`; treemap `parent_label_layout: "banner" | "overlapping" | "none"` (default `overlapping`), PowerPoint labels only the top level and leaves), `histogram` (`values`), `pareto` / `waterfall` / `funnel` (`categories` + `values`; `waterfall` accepts `subtotals` / `subtotal_indices`), `boxWhisker` (`series[].values`, optional `series[].categories`). ChartEx writes no `<cx:title>` without a payload title (an empty title shows the series name), emits title/subtitle as companion text boxes, and takes `style.colors` / root `colors` into its color-style part. ChartEx accepts no `plot_area`, `axes`, or `data_labels` (each is refused); values reach the slide only through companion text: register every drawn value label as its own `notes[]` entry with unique text (each is placed from its fallback `<text>` and survives the native export), and draw no tick labels, since an axis has no channel — or choose a classic type when the labels must be bound to the marks. Non-Microsoft renderers show a limited subset.
|
||||
- **Stock** — numeric Excel date serials in `categories` / `dates` plus exactly four series open / high / low / close (`series` with four entries or top-level `open` / `high` / `low` / `close`).
|
||||
- **Supported types**: `column`, `bar` (`grouping`: `clustered` / `stacked` / `percentStacked`); `line` (`grouping`: `standard` / `stacked` / `percentStacked`; `line_style`: `line` / `lineMarker`, default `line` with no markers); `area` (`grouping`: `standard` / `stacked` / `percentStacked`); `pie`, `doughnut`, `pieOfPie`, `barOfPie`; `radar`, `radarMarkers`, `radarFilled`; `scatter` (`scatter_style`: `marker` / `lineMarker` / `line` / `smoothMarker` / `smooth`, default `marker`); `bubble`; `combo`; `treemap`, `sunburst`; `histogram`, `pareto`; `boxWhisker`; `waterfall`, `funnel`; `stock`. 3D aliases and `surface` are unsupported; exploded pie/doughnut, `map`, `heatmap`, `bullet`, and `gantt` are deferred and fail fast.
|
||||
|
||||
|
||||
@@ -1027,7 +1027,7 @@ class-average estimate, with the existing fixed advances for monospaced faces.
|
||||
- `box` prints a `data-pptx-bounds` attribute plus numeric `top` and `bottom`, or
|
||||
a JSON bounds object with `--json`.
|
||||
- `calibrate` measures fixed CJK and Latin samples for every typography role
|
||||
from `spec_lock.md` or repeatable `--role NAME:FAMILY:SIZE` overrides, writes
|
||||
from `spec_lock.md` or repeatable `--role NAME:FAMILY:SIZE[:bold]` overrides, writes
|
||||
`validation/text_calibration.json`, and prints a compact table or JSON. The
|
||||
estimator is additive across scripts, so a line mixing CJK with Latin words
|
||||
or digits is estimated as (CJK chars ÷ CJK rate + other chars ÷ Latin rate)
|
||||
@@ -1050,6 +1050,7 @@ class-average estimate, with the existing fixed advances for monospaced faces.
|
||||
|
||||
```bash
|
||||
python3 scripts/text_measure.py measure "Editable DrawingML text" --size 22
|
||||
python3 scripts/text_measure.py measure --size 22 -- "34.5%" "-1.3%" # values that start with "-" go after --; a paragraph over 255 characters goes through --stdin
|
||||
python3 scripts/text_measure.py wrap "Editable DrawingML text stays measurable" --size 22 --max-width 240 --x 96 --dy 30 --y 140
|
||||
python3 scripts/text_measure.py box "First line" "Second line" --x 96 --y 140 --size 22 --lines 2 --dy 30
|
||||
python3 scripts/text_measure.py calibrate projects/example --outline
|
||||
@@ -1115,6 +1116,7 @@ Use this after `svg_quality_checker.py` passes, and only for chart types support
|
||||
|
||||
```bash
|
||||
python3 scripts/svg_position_calculator.py calc bar --data "A:185,B:142" --area "130,155,1200,480" --bar-width 120
|
||||
python3 scripts/svg_position_calculator.py calc bar --data "A:185,B:142" --area "130,155,1200,480" --gap-width 150 # native-ready: equal category slots, bar width = slot / (1 + gap_width/100)
|
||||
python3 scripts/svg_position_calculator.py calc line --data "0:50,10:80,20:120" --area "120,120,1200,600" --y-range "0,150"
|
||||
python3 scripts/svg_position_calculator.py calc pie --data "A:35,B:25,C:20" --center "420,400" --radius 200
|
||||
python3 scripts/svg_position_calculator.py calc grid --rows 2 --cols 3 --area "50,150,1230,670"
|
||||
|
||||
@@ -548,6 +548,7 @@ STRUCTURAL_IMAGE_TYPES = {
|
||||
}
|
||||
LEGACY_IMAGE_TYPES = {"background", "hero", "portrait", "typography"}
|
||||
EARLY_LEGACY_IMAGE_TYPES = {"illustration", "photography"}
|
||||
SHEET_TYPE_SPELLINGS = {"illustration sheet", "sheet"}
|
||||
VALID_IMAGE_TYPES = (
|
||||
STRUCTURAL_IMAGE_TYPES
|
||||
| LEGACY_IMAGE_TYPES
|
||||
@@ -710,6 +711,11 @@ def load_manifest(path: str) -> dict:
|
||||
)
|
||||
|
||||
image_type = item.get("type")
|
||||
if isinstance(image_type, str) and image_type.strip().lower() in SHEET_TYPE_SPELLINGS:
|
||||
# The resource-row column reads "Illustration Sheet"; the manifest
|
||||
# item omits type. Accept the row spelling as that omission.
|
||||
item.pop("type")
|
||||
image_type = None
|
||||
if image_type is not None:
|
||||
normalized_type = (
|
||||
image_type.strip().lower()
|
||||
|
||||
@@ -134,7 +134,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
required=True,
|
||||
help="New bare .png filename under <project_path>/images; existing files are never replaced.",
|
||||
help="New bare .png filename under <project_path>/images (or .jpg for an opaque photographic derivative such as a --fit downscale); existing files are never replaced.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--brightness",
|
||||
@@ -207,8 +207,8 @@ def _assert_output_absent(output_path: Path) -> None:
|
||||
def _resolve_paths(project_value: str, source_name: str, output_name: str) -> tuple[Path, Path, Path]:
|
||||
source_name = _validate_bare_filename(source_name, field_name="source")
|
||||
output_name = _validate_bare_filename(output_name, field_name="output")
|
||||
if Path(output_name).suffix.casefold() != ".png":
|
||||
raise ValueError("output must use the .png extension")
|
||||
if Path(output_name).suffix.casefold() not in {".png", ".jpg", ".jpeg"}:
|
||||
raise ValueError("output must use the .png extension, or .jpg for an opaque photographic derivative")
|
||||
if source_name.casefold() == output_name.casefold():
|
||||
raise ValueError("output must differ from source, including filename casing")
|
||||
|
||||
@@ -388,7 +388,20 @@ def _apply_treatments(
|
||||
result.close()
|
||||
result = resized
|
||||
width, height = new_size
|
||||
save_options = {"format": "PNG"}
|
||||
if getattr(args, "output_format", "PNG") == "JPEG":
|
||||
if alpha is not None:
|
||||
raise ValueError(
|
||||
"JPEG output cannot hold the source's transparency; "
|
||||
"use a .png output for this source"
|
||||
)
|
||||
save_options = {"format": "JPEG", "quality": 90, "optimize": True}
|
||||
if result.mode != "RGB":
|
||||
converted = result.convert("RGB")
|
||||
if result is not rgb:
|
||||
result.close()
|
||||
result = converted
|
||||
else:
|
||||
save_options = {"format": "PNG"}
|
||||
if isinstance(icc_profile, bytes) and icc_profile:
|
||||
save_options["icc_profile"] = icc_profile
|
||||
result.save(temporary_path, **save_options)
|
||||
@@ -543,6 +556,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
args.source,
|
||||
args.output,
|
||||
)
|
||||
args.output_format = (
|
||||
"JPEG" if output_path.suffix.casefold() in {".jpg", ".jpeg"} else "PNG"
|
||||
)
|
||||
treatments = _treatment_plan(args)
|
||||
manifest_path = images_path / "image_sources.json"
|
||||
provenance_item = _prepare_provenance(
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
"skills/ppt-master/SKILL.md": 2500,
|
||||
"skills/ppt-master/references/executor-base.md": 12000,
|
||||
"skills/ppt-master/references/executor-structured.md": 5500,
|
||||
"skills/ppt-master/references/executor-chart.md": 1750,
|
||||
"skills/ppt-master/references/executor-chart.md": 2000,
|
||||
"skills/ppt-master/references/executor-visualization.md": 1000,
|
||||
"skills/ppt-master/references/executor-structure.md": 1750,
|
||||
"skills/ppt-master/references/topology-assembly.md": 3750,
|
||||
|
||||
@@ -75,6 +75,12 @@ _KEY_DRIFT_MARGIN = 4
|
||||
_KEY_PURITY_OPAQUE_RATIO = 0.6
|
||||
# At most this many trim pixels on a touched edge count as isolated drift.
|
||||
_EDGE_DRIFT_MAX_PIXELS = 8
|
||||
# Strict-alpha haze gate: when this share of an element's trimmed box sits in
|
||||
# the semi-transparent alpha band, the key field itself was recovered as soft
|
||||
# alpha (the sheet ground is not the stated key), not an element edge.
|
||||
_HAZE_ALPHA_LOW = 20
|
||||
_HAZE_ALPHA_HIGH = 150
|
||||
_HAZE_MAX_SHARE = 0.15
|
||||
# Corner sample inset (px) and minimum cell fill for the backing-panel notice.
|
||||
_PANEL_CORNER_INSET = 2
|
||||
_PANEL_MIN_FILL = 0.75
|
||||
@@ -96,6 +102,20 @@ def parse_grid(spec: str) -> tuple[int, int]:
|
||||
return rows, cols
|
||||
|
||||
|
||||
def parse_inset(spec: str) -> tuple[float, float]:
|
||||
"""Parse `--inset` as one fraction or `H,V`; both must lie in [0, 0.5)."""
|
||||
parts = [part.strip() for part in str(spec).split(",")]
|
||||
if len(parts) not in (1, 2) or not all(parts):
|
||||
raise ValueError("--inset must be one fraction or H,V")
|
||||
try:
|
||||
values = [float(part) for part in parts]
|
||||
except ValueError as exc:
|
||||
raise ValueError("--inset must be one fraction or H,V") from exc
|
||||
if not all(0.0 <= value < 0.5 for value in values):
|
||||
raise ValueError("--inset must be in [0, 0.5)")
|
||||
return (values[0], values[-1])
|
||||
|
||||
|
||||
def parse_hex(value: str) -> tuple[int, int, int]:
|
||||
"""Parse '#RRGGBB' / 'RRGGBB' into an (r, g, b) tuple."""
|
||||
h = value.strip().lstrip("#")
|
||||
@@ -504,14 +524,24 @@ def _keying_findings(
|
||||
if touched_edges:
|
||||
drift = _edge_drift(trim_mask, diff, touched_edges)
|
||||
if drift is not None:
|
||||
count, distance = drift
|
||||
findings.append(
|
||||
f"{label}: {count} isolated pixel(s) on the "
|
||||
f"{'/'.join(touched_edges)} cell edge(s) exceed the key "
|
||||
f"tolerance (farthest {distance} from key {hex_bg}); this is "
|
||||
f"key drift, not content — rerun with --tolerance "
|
||||
f"{distance + _KEY_DRIFT_MARGIN} or higher"
|
||||
)
|
||||
count, distance, isolated = drift
|
||||
if isolated:
|
||||
findings.append(
|
||||
f"{label}: {count} isolated pixel(s) on the "
|
||||
f"{'/'.join(touched_edges)} cell edge(s) exceed the key "
|
||||
f"tolerance (farthest {distance} from key {hex_bg}); this is "
|
||||
f"key drift, not content — rerun with --tolerance "
|
||||
f"{distance + _KEY_DRIFT_MARGIN} or higher"
|
||||
)
|
||||
else:
|
||||
findings.append(
|
||||
f"{label}: {count} pixel(s) on the "
|
||||
f"{'/'.join(touched_edges)} cell edge(s) exceed the key "
|
||||
f"tolerance but all stay within {distance} of key {hex_bg}; "
|
||||
f"this is key noise (JPEG compression or a slightly off "
|
||||
f"key), not content — rerun with --tolerance "
|
||||
f"{distance + _KEY_DRIFT_MARGIN} or higher"
|
||||
)
|
||||
else:
|
||||
findings.append(
|
||||
f"{label}: content reaches the {'/'.join(touched_edges)} "
|
||||
@@ -521,6 +551,39 @@ def _keying_findings(
|
||||
return findings
|
||||
|
||||
|
||||
def _haze_finding(
|
||||
label: str,
|
||||
alpha_mask: Image.Image,
|
||||
bbox: tuple[int, int, int, int],
|
||||
bg: tuple[int, int, int],
|
||||
) -> Optional[str]:
|
||||
"""Report an element box that is mostly semi-transparent haze.
|
||||
|
||||
Soft-alpha recovery assumes the key field lies at the stated key; when the
|
||||
real ground sits farther than the tolerance, the whole field comes back as
|
||||
faint half-foreground that reads as a glowing rectangle on a dark slide.
|
||||
`--strict-alpha` only inspects the outer gutter, so this gate looks at the
|
||||
trimmed box itself.
|
||||
"""
|
||||
box = alpha_mask.crop(bbox)
|
||||
total = box.width * box.height
|
||||
if total == 0:
|
||||
return None
|
||||
histogram = box.histogram()
|
||||
hazy = sum(histogram[_HAZE_ALPHA_LOW:_HAZE_ALPHA_HIGH + 1])
|
||||
share = hazy / total
|
||||
if share <= _HAZE_MAX_SHARE:
|
||||
return None
|
||||
hex_bg = "#{:02X}{:02X}{:02X}".format(*bg)
|
||||
return (
|
||||
f"{label}: {share:.0%} of the trimmed element box is semi-transparent "
|
||||
f"(alpha {_HAZE_ALPHA_LOW}-{_HAZE_ALPHA_HIGH}): the key field itself "
|
||||
f"was recovered as soft alpha, so the sheet ground is not {hex_bg}; "
|
||||
"rerun with --bg set to the measured ground colour (see the sheet "
|
||||
"border line below) instead of the nearest pure key"
|
||||
)
|
||||
|
||||
|
||||
def _backing_panel_notice(
|
||||
label: str,
|
||||
alpha_mask: Image.Image,
|
||||
@@ -563,9 +626,12 @@ def _edge_drift(
|
||||
trim_mask: Optional[Image.Image],
|
||||
diff: Optional[Image.Image],
|
||||
touched_edges: list[str],
|
||||
) -> Optional[tuple[int, int]]:
|
||||
"""Return (pixel count, max key distance) when the touched edges hold only
|
||||
a few near-key pixels, or None when real content reaches an edge."""
|
||||
) -> Optional[tuple[int, int, bool]]:
|
||||
"""Return (pixel count, max key distance, isolated) when every pixel on
|
||||
the touched edges stays near the key, or None when real content reaches
|
||||
an edge. ``isolated`` is True for a handful of stray pixels; False means
|
||||
the whole edge is near-key noise (JPEG ringing, a slightly off key), which
|
||||
is still keying drift rather than content."""
|
||||
if trim_mask is None or diff is None:
|
||||
return None
|
||||
width, height = trim_mask.size
|
||||
@@ -581,12 +647,12 @@ def _edge_drift(
|
||||
if "bottom" in touched_edges:
|
||||
coords.update((x, height - 1) for x in range(width))
|
||||
hits = [diff_px[x, y] for x, y in coords if mask_px[x, y]]
|
||||
if not hits or len(hits) > _EDGE_DRIFT_MAX_PIXELS:
|
||||
if not hits:
|
||||
return None
|
||||
distance = max(hits)
|
||||
if distance > _KEY_DRIFT_MAX_TOLERANCE:
|
||||
return None
|
||||
return len(hits), distance
|
||||
return len(hits), distance, len(hits) <= _EDGE_DRIFT_MAX_PIXELS
|
||||
|
||||
|
||||
def _log_keying_findings(
|
||||
@@ -594,18 +660,32 @@ def _log_keying_findings(
|
||||
*,
|
||||
sheet_border: tuple[tuple[int, int, int], int, int] | None = None,
|
||||
tolerance: int,
|
||||
notices: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Report incomplete flat-background keying."""
|
||||
_log("\n[WARN] Alpha extraction is incomplete — the key field or cell")
|
||||
_log(" isolation failed:")
|
||||
for finding in findings:
|
||||
_log(f" - {finding}")
|
||||
# A field recovered as haze is an off-key ground, not a panel: keep the
|
||||
# measured-border rerun advice for it instead of the panel verdict.
|
||||
hazy = any("semi-transparent" in finding for finding in findings)
|
||||
panel_notices = [] if hazy else [n for n in (notices or []) if "backing panel" in n]
|
||||
for notice in panel_notices:
|
||||
_log(f" - {notice}")
|
||||
if panel_notices:
|
||||
_log(" Cells painted as panels or cards keep their own ground "
|
||||
"inside the key gutters: no --bg/--tolerance rerun on the outer "
|
||||
"key can remove them. Regenerate with each element alone on the "
|
||||
"key, or key each cell on its measured inner ground.")
|
||||
return
|
||||
_log(" Fix: regenerate with one genuinely flat ground and keep every "
|
||||
"element/effect")
|
||||
_log(" inside its cell with a clear key-only gutter, or rerun with an "
|
||||
"explicit")
|
||||
_log(" --bg <hex> and a larger --tolerance; use --inset when a drawn "
|
||||
"outer gutter is isolated from every element.")
|
||||
"outer gutter is isolated from every element, or when the model drew "
|
||||
"grid lines between cells (--inset 0.02, or H,V for wide cells).")
|
||||
if sheet_border is not None:
|
||||
dominant, drift, outlier = sheet_border
|
||||
hex_bg = "#{:02X}{:02X}{:02X}".format(*dominant)
|
||||
@@ -660,7 +740,7 @@ def slice_sheet(
|
||||
*,
|
||||
names: Optional[list[str]] = None,
|
||||
prefix: Optional[str] = None,
|
||||
inset: float = 0.0,
|
||||
inset: float | tuple[float, float] = 0.0,
|
||||
trim: bool = False,
|
||||
alpha: bool = False,
|
||||
strict_alpha: bool = False,
|
||||
@@ -674,6 +754,7 @@ def slice_sheet(
|
||||
automated run never silently drops cells. Each name must be a bare filename.
|
||||
"""
|
||||
total_cells = rows * cols
|
||||
inset_x, inset_y = inset if isinstance(inset, tuple) else (inset, inset)
|
||||
if strict_alpha and not alpha:
|
||||
raise ValueError("strict_alpha requires alpha=True")
|
||||
if names is not None and len(names) != total_cells:
|
||||
@@ -717,9 +798,9 @@ def slice_sheet(
|
||||
# Integer cell box via per-index rounding to avoid drift.
|
||||
x0, x1 = round(c * sw / cols), round((c + 1) * sw / cols)
|
||||
y0, y1 = round(r * sh / rows), round((r + 1) * sh / rows)
|
||||
if inset > 0:
|
||||
dx = round((x1 - x0) * inset)
|
||||
dy = round((y1 - y0) * inset)
|
||||
if inset_x > 0 or inset_y > 0:
|
||||
dx = round((x1 - x0) * inset_x)
|
||||
dy = round((y1 - y0) * inset_y)
|
||||
x0, x1, y0, y1 = x0 + dx, x1 - dx, y0 + dy, y1 - dy
|
||||
cell = sheet.crop((x0, y0, x1, y1))
|
||||
|
||||
@@ -740,6 +821,10 @@ def slice_sheet(
|
||||
trim=trim, alpha=alpha, trim_mask=trim_mask, diff=diff,
|
||||
notices=notices,
|
||||
))
|
||||
if strict_alpha:
|
||||
haze = _haze_finding(f"cell ({r},{c})", alpha_mask, bbox, cell_bg)
|
||||
if haze:
|
||||
findings.append(haze)
|
||||
|
||||
if trim and trim_mask is not None and alpha_mask is not None and bbox is not None:
|
||||
cell = cell.crop(bbox)
|
||||
@@ -768,6 +853,7 @@ def slice_sheet(
|
||||
findings,
|
||||
sheet_border=sheet_border,
|
||||
tolerance=tolerance,
|
||||
notices=notices,
|
||||
)
|
||||
if strict_alpha:
|
||||
raise ValueError(
|
||||
@@ -817,8 +903,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="Filename prefix when --names is absent (default: '<sheet-stem>_')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inset", type=float, default=0.0,
|
||||
help="Trim each cell inward by this fraction on every side (0-0.49) to drop gutters",
|
||||
"--inset", type=str, default="0",
|
||||
help=(
|
||||
"Trim each cell inward by this fraction (0-0.49) to drop gutters: "
|
||||
"one value for every side, or H,V (e.g. 0.01,0.03) when wide cells "
|
||||
"only need the horizontal grid line trimmed"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trim", action="store_true",
|
||||
@@ -863,8 +953,10 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not 0.0 <= args.inset < 0.5:
|
||||
print("[ERROR] --inset must be in [0, 0.5)", file=sys.stderr)
|
||||
try:
|
||||
inset = parse_inset(args.inset)
|
||||
except ValueError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if not 0 <= args.tolerance <= 255:
|
||||
print("[ERROR] --tolerance must be in [0, 255]", file=sys.stderr)
|
||||
@@ -879,7 +971,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
try:
|
||||
written = slice_sheet(
|
||||
sheet_path, rows, cols, output_dir,
|
||||
names=names, prefix=args.prefix, inset=args.inset,
|
||||
names=names, prefix=args.prefix, inset=inset,
|
||||
trim=args.trim, alpha=args.alpha, strict_alpha=args.strict_alpha,
|
||||
bg=bg, tolerance=args.tolerance,
|
||||
)
|
||||
|
||||
+71
-10
@@ -32,9 +32,11 @@ import argparse
|
||||
import codecs
|
||||
import datetime
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -76,21 +78,65 @@ except ImportError:
|
||||
_CURL_IMPERSONATE = None
|
||||
|
||||
|
||||
class _UnsafeUrlError(ValueError):
|
||||
"""Reject a URL that cannot be verified as a public HTTP(S) target."""
|
||||
|
||||
|
||||
def _validate_public_url(url: str) -> None:
|
||||
"""Reject non-HTTP(S) URLs and hosts resolving to non-public addresses."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError as exc:
|
||||
raise _UnsafeUrlError(f"Invalid URL: {exc}") from exc
|
||||
if parsed.scheme not in {"http", "https"} or not hostname:
|
||||
raise _UnsafeUrlError("Only HTTP(S) URLs with a hostname are allowed")
|
||||
try:
|
||||
addresses = [ipaddress.ip_address(hostname)]
|
||||
except ValueError:
|
||||
try:
|
||||
addresses = [
|
||||
ipaddress.ip_address(info[4][0])
|
||||
for info in socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
]
|
||||
except (OSError, ValueError) as exc:
|
||||
raise _UnsafeUrlError(f"Cannot validate URL hostname {hostname}: {exc}") from exc
|
||||
if not addresses:
|
||||
raise _UnsafeUrlError(f"Cannot resolve URL hostname {hostname}")
|
||||
if CONFIG["allow_private_hosts"]:
|
||||
return
|
||||
for address in addresses:
|
||||
if (address.is_loopback or address.is_link_local
|
||||
or address.is_private or address.is_unspecified):
|
||||
raise _UnsafeUrlError(
|
||||
f"Refusing non-public URL target: {hostname} resolves to {address} "
|
||||
"(pass --allow-private-hosts for intranet or localhost pages)"
|
||||
)
|
||||
|
||||
|
||||
def _http_get(url: str, *, headers: dict | None = None, timeout: int | None = None,
|
||||
verify: bool = False, stream: bool = False):
|
||||
verify: bool = True, stream: bool = False):
|
||||
"""HTTP GET with curl_cffi preferred, requests fallback.
|
||||
|
||||
Using curl_cffi lets this script fetch sites that reject Python's default
|
||||
TLS fingerprint (notably mp.weixin.qq.com). Signature mirrors the subset of
|
||||
requests.get() this script actually uses.
|
||||
"""
|
||||
_validate_public_url(url)
|
||||
if curl_requests is not None:
|
||||
return curl_requests.get(
|
||||
response = curl_requests.get(
|
||||
url, headers=headers, timeout=timeout,
|
||||
verify=verify, impersonate=_CURL_IMPERSONATE, stream=stream,
|
||||
)
|
||||
return requests.get(url, headers=headers, timeout=timeout,
|
||||
verify=verify, stream=stream)
|
||||
else:
|
||||
response = requests.get(url, headers=headers, timeout=timeout,
|
||||
verify=verify, stream=stream)
|
||||
try:
|
||||
_validate_public_url(response.url)
|
||||
except _UnsafeUrlError:
|
||||
response.close()
|
||||
raise
|
||||
return response
|
||||
|
||||
|
||||
def _normalize_charset(charset: str | None) -> str:
|
||||
@@ -194,6 +240,8 @@ except ImportError:
|
||||
CONFIG = {
|
||||
"output_dir": "./projects",
|
||||
"timeout": 30,
|
||||
"insecure": False,
|
||||
"allow_private_hosts": False,
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
# Specific content identifiers often found in Chinese CMS (Gov/News)
|
||||
"content_selectors": [
|
||||
@@ -241,7 +289,7 @@ def fetch_url(url: str) -> tuple[str, str]:
|
||||
|
||||
try:
|
||||
response = _http_get(url, headers=headers,
|
||||
timeout=CONFIG["timeout"], verify=False)
|
||||
timeout=CONFIG["timeout"], verify=not CONFIG["insecure"])
|
||||
response.raise_for_status()
|
||||
|
||||
return _decode_response_text(response), response.url
|
||||
@@ -377,7 +425,7 @@ def download_and_rewrite_images(
|
||||
abs_url,
|
||||
headers={"User-Agent": CONFIG["user_agent"]},
|
||||
timeout=CONFIG["timeout"],
|
||||
verify=False,
|
||||
verify=not CONFIG["insecure"],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
filename = build_image_filename(
|
||||
@@ -455,6 +503,8 @@ def download_and_rewrite_images(
|
||||
"occurrences": [],
|
||||
}
|
||||
saved += 1
|
||||
except _UnsafeUrlError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f" [WARN] Skip image {abs_url}: {e}")
|
||||
continue
|
||||
@@ -1040,8 +1090,23 @@ def main(argv: list[str] | None = None) -> int:
|
||||
action="store_true",
|
||||
help="Keep remote image links without downloading image files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
help="Disable TLS certificate verification (only for self-signed development targets)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-private-hosts",
|
||||
action="store_true",
|
||||
help="Allow pages, images, and redirect targets on loopback, link-local, "
|
||||
"and private networks (intranet or localhost pages you trust)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
CONFIG["insecure"] = args.insecure
|
||||
CONFIG["allow_private_hosts"] = args.allow_private_hosts
|
||||
if args.insecure:
|
||||
print("[WARN] TLS certificate verification disabled (--insecure)", file=sys.stderr)
|
||||
|
||||
if args.dir:
|
||||
CONFIG["output_dir"] = args.dir
|
||||
@@ -1099,8 +1164,4 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not _HELP_REQUESTED:
|
||||
# Disable warnings for verify=False if needed, though often useful to see
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
raise SystemExit(main())
|
||||
@@ -30,6 +30,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -455,6 +456,20 @@ def create_app(
|
||||
|
||||
@app.before_request
|
||||
def _update_activity():
|
||||
allowed_hosts = {PUBLIC_HOST, 'localhost', '::1', '[::1]'}
|
||||
host = request.headers.get('Host', '').lower()
|
||||
if host.startswith('[') or host.count(':') == 1:
|
||||
host = re.sub(r':\d+$', '', host)
|
||||
if host not in allowed_hosts:
|
||||
return jsonify({'error': 'Forbidden Host header'}), 403
|
||||
origin = request.headers.get('Origin')
|
||||
if origin is not None:
|
||||
try:
|
||||
origin_host = urllib.parse.urlsplit(origin).hostname
|
||||
except ValueError:
|
||||
origin_host = None
|
||||
if origin_host not in allowed_hosts:
|
||||
return jsonify({'error': 'Forbidden Origin header'}), 403
|
||||
app.config['LAST_REQUEST_TIME'] = time.time()
|
||||
|
||||
def _exit_with_lock_release(code: int = 0) -> None:
|
||||
|
||||
+22
-7
@@ -1848,18 +1848,32 @@
|
||||
// ================================================================
|
||||
// Utility
|
||||
// ================================================================
|
||||
var SVG_NS = "http://www.w3.org/2000/svg";
|
||||
var SVG_ALLOWED_ELEMENTS = new Set([
|
||||
"svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse",
|
||||
"line", "polyline", "polygon", "text", "tspan", "textpath", "image",
|
||||
"clippath", "mask", "pattern", "marker", "lineargradient", "radialgradient",
|
||||
"stop", "filter", "feblend", "fecolormatrix", "fecomponenttransfer",
|
||||
"fecomposite", "feconvolvematrix", "fediffuselighting", "fedisplacementmap",
|
||||
"fedistantlight", "fedropshadow", "feflood", "fefunca", "fefuncb", "fefuncg",
|
||||
"fefuncr", "fegaussianblur", "feimage", "femerge", "femergenode",
|
||||
"femorphology", "feoffset", "fepointlight", "fespecularlighting",
|
||||
"fespotlight", "fetile", "feturbulence", "style", "title", "desc", "metadata",
|
||||
"a", "switch", "view", "animate", "animatecolor", "animatetransform",
|
||||
"animatemotion", "set", "mpath"
|
||||
]);
|
||||
|
||||
function sanitizeSvg(svgString) {
|
||||
var doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
|
||||
if (doc.getElementsByTagName("parsererror").length ||
|
||||
!doc.documentElement || doc.documentElement.localName.toLowerCase() !== "svg") {
|
||||
!doc.documentElement || doc.documentElement.localName.toLowerCase() !== "svg" ||
|
||||
doc.documentElement.namespaceURI !== SVG_NS) {
|
||||
throw new Error(t("err_empty_svg"));
|
||||
}
|
||||
doc.querySelectorAll("*").forEach(function (el) {
|
||||
// XML preserves case; the innerHTML sink normalizes HTML/SVG names.
|
||||
var tag = el.localName.toLowerCase();
|
||||
if (tag === "script" || tag === "foreignobject" ||
|
||||
(el.namespaceURI && el.namespaceURI !== "http://www.w3.org/2000/svg" &&
|
||||
el.namespaceURI !== "http://www.w3.org/1999/xlink")) {
|
||||
if (!SVG_ALLOWED_ELEMENTS.has(tag) || el.namespaceURI !== SVG_NS) {
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
@@ -1871,10 +1885,11 @@
|
||||
return;
|
||||
}
|
||||
// Strip dangerous URI protocols from href/xlink:href
|
||||
var value = attr.value.replace(/[\t\r\n]/g, "").replace(/^[\x00-\x20]+/, "");
|
||||
if (local === "href" &&
|
||||
(/^\s*javascript\s*:/i.test(attr.value) ||
|
||||
/^\s*vbscript\s*:/i.test(attr.value) ||
|
||||
/^\s*data\s*:/i.test(attr.value))) {
|
||||
(/^\s*javascript\s*:/i.test(value) ||
|
||||
/^\s*vbscript\s*:/i.test(value) ||
|
||||
/^\s*data\s*:/i.test(value))) {
|
||||
el.removeAttributeNode(attr);
|
||||
}
|
||||
});
|
||||
|
||||
+4
-1
@@ -264,7 +264,10 @@ def _build_paragraph_child_view(
|
||||
|
||||
raw_lead = text_el.text or ""
|
||||
synthetic_first: ET.Element | None = None
|
||||
if raw_lead.strip():
|
||||
# A first visual line may also open with an inline run (a bold lead word)
|
||||
# and no direct text; it is the same leading line with an empty prefix.
|
||||
leads_with_inline_run = bool(direct_tspans) and not _is_new_line_tspan(direct_tspans[0])
|
||||
if raw_lead.strip() or leads_with_inline_run:
|
||||
base_x_raw = get_attr(text_el, "x")
|
||||
if base_x_raw is None:
|
||||
return None
|
||||
|
||||
+49
-16
@@ -963,21 +963,34 @@ def parse_data_string(data_str: str) -> Dict[str, float]:
|
||||
|
||||
|
||||
def parse_xy_data_string(data_str: str) -> List[Tuple[float, float]]:
|
||||
"""Parse XY data string in 'x1:y1,x2:y2' format"""
|
||||
result = []
|
||||
"""Parse XY data string in 'x1:y1,x2:y2' format.
|
||||
|
||||
A categorical series (`K:45.6,1:38.7,…`, `2018-19:26.4,…`) maps its labels
|
||||
to the ordinals 1..n, the same slot model `calc bar` uses, so a line or
|
||||
area chart over school years or grades verifies as drawn. A value that
|
||||
cannot be read is an error rather than a silently dropped point: a
|
||||
verifier that changes the point count verifies a different chart.
|
||||
"""
|
||||
pairs: List[Tuple[str, float]] = []
|
||||
for item in data_str.split(','):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
if ':' in item:
|
||||
x, y = item.split(':', 1)
|
||||
try:
|
||||
result.append((float(x.strip()), float(y.strip())))
|
||||
except ValueError:
|
||||
print(f"[Warning] Unable to parse coordinates: '{item}', skipped")
|
||||
else:
|
||||
print(f"[Warning] Invalid format (expected 'x:y'): '{item}'")
|
||||
return result
|
||||
if ':' not in item:
|
||||
raise ValueError(f"Invalid format (expected 'x:y'): '{item}'")
|
||||
x, y = item.rsplit(':', 1)
|
||||
try:
|
||||
pairs.append((x.strip(), float(y.strip())))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unable to parse value: '{item}'") from exc
|
||||
try:
|
||||
return [(float(x), y) for x, y in pairs]
|
||||
except ValueError:
|
||||
print(
|
||||
f"[Note] Category axis: {len(pairs)} labels mapped to ordinals 1..{len(pairs)} "
|
||||
"(pass --x-range=0.5,N+0.5 to centre them in equal slots)"
|
||||
)
|
||||
return [(float(index), y) for index, (_label, y) in enumerate(pairs, start=1)]
|
||||
|
||||
|
||||
def parse_tuple(s: str) -> Tuple[float, ...]:
|
||||
@@ -1168,7 +1181,11 @@ def interactive_mode() -> None:
|
||||
canvas = input("Canvas format [ppt169]: ").strip() or 'ppt169'
|
||||
coord = CoordinateSystem(canvas)
|
||||
calc = LineChartCalculator(coord)
|
||||
data = parse_xy_data_string(data_str)
|
||||
try:
|
||||
data = parse_xy_data_string(data_str)
|
||||
except ValueError as exc:
|
||||
print(f"[Error] {exc}")
|
||||
continue
|
||||
points = calc.calculate(data)
|
||||
print()
|
||||
print(calc.format_table(points))
|
||||
@@ -1328,6 +1345,9 @@ Common commands:
|
||||
bar_parser.add_argument('--canvas', default='ppt169', help='Canvas format')
|
||||
bar_parser.add_argument('--area', help='Chart area "x_min,y_min,x_max,y_max"')
|
||||
bar_parser.add_argument('--bar-width', type=float, default=50, help='Bar width')
|
||||
bar_parser.add_argument('--gap-width', type=float, default=None,
|
||||
help='PowerPoint category gap width in percent of the bar (native payload gap_width, default 150): '
|
||||
'lays categories out in equal slots across the area and derives the bar width, overriding --bar-width')
|
||||
bar_parser.add_argument('--horizontal', action='store_true', help='Horizontal bar chart')
|
||||
bar_parser.add_argument('--value-range', help='Value axis range "min,max" (from axis tick labels; omit to auto-normalize)')
|
||||
|
||||
@@ -1411,11 +1431,21 @@ Common commands:
|
||||
parser.error('calc bar --value-range max must be greater than min')
|
||||
scale_source = f'axis ticks ({v_min}-{v_max})'
|
||||
|
||||
positions = calc.calculate(data, bar_width=args.bar_width,
|
||||
horizontal=args.horizontal,
|
||||
y_min=v_min, y_max=v_max)
|
||||
if args.gap_width is not None:
|
||||
if args.gap_width < 0:
|
||||
parser.error('calc bar --gap-width must be zero or positive')
|
||||
positions = calc.calculate(data, bar_width=None,
|
||||
gap_ratio=args.gap_width / 100.0,
|
||||
horizontal=args.horizontal,
|
||||
y_min=v_min, y_max=v_max)
|
||||
else:
|
||||
positions = calc.calculate(data, bar_width=args.bar_width,
|
||||
horizontal=args.horizontal,
|
||||
y_min=v_min, y_max=v_max)
|
||||
|
||||
print(f"\n=== Bar Chart Coordinate Calculation ===")
|
||||
if args.gap_width is not None:
|
||||
print(f"Category layout: PowerPoint slots, gap_width {args.gap_width:g}% (bar width derived)")
|
||||
print(f"Canvas: {CANVAS_FORMATS.get(canvas, {}).get('dimensions', canvas)}")
|
||||
print(f"Chart area: ({coord.chart_area.x_min}, {coord.chart_area.y_min}) - "
|
||||
f"({coord.chart_area.x_max}, {coord.chart_area.y_max})")
|
||||
@@ -1445,7 +1475,10 @@ Common commands:
|
||||
canvas = args.canvas if hasattr(args, 'canvas') else 'ppt169'
|
||||
coord = CoordinateSystem(canvas, chart_area)
|
||||
calc = LineChartCalculator(coord)
|
||||
data = parse_xy_data_string(args.data)
|
||||
try:
|
||||
data = parse_xy_data_string(args.data)
|
||||
except ValueError as exc:
|
||||
parser.error(f'calc line --data: {exc}')
|
||||
|
||||
x_range = parse_tuple(args.x_range) if args.x_range else None
|
||||
y_range = parse_tuple(args.y_range) if args.y_range else None
|
||||
|
||||
@@ -3260,7 +3260,10 @@ class SVGQualityChecker:
|
||||
return None
|
||||
line_groups = None
|
||||
synthetic_first = None
|
||||
if (text_el.text or '').strip():
|
||||
leads_with_inline_run = (
|
||||
cls._is_tspan(children[0]) and not cls._is_line_tspan(children[0])
|
||||
)
|
||||
if (text_el.text or '').strip() or leads_with_inline_run:
|
||||
if _classify_paragraph_block is None:
|
||||
return None
|
||||
paragraph = _classify_paragraph_block(
|
||||
@@ -4905,7 +4908,9 @@ class SVGQualityChecker:
|
||||
f"{int(display_w)}x{int(display_h)} {fit_label} frame; "
|
||||
f"the source is {source_mib:.1f} MiB — file-size "
|
||||
"advisory only, not an aspect-ratio warning; consider "
|
||||
"a smaller source asset"
|
||||
"a smaller source asset, or export with "
|
||||
"svg_to_pptx.py --image-sizing display to downsize "
|
||||
"at export time"
|
||||
)
|
||||
except ImportError:
|
||||
pass # PIL not available, skip resolution check
|
||||
@@ -7857,11 +7862,69 @@ class SVGQualityChecker:
|
||||
)
|
||||
if resolved is not None:
|
||||
references[filename].add(resolved.resolve())
|
||||
# A text picture fill keeps its <image> inside <defs><pattern
|
||||
# data-pptx-text-image-fill>; the glyphs that reference the
|
||||
# pattern render that file, so it counts as a placement.
|
||||
for element in cls._text_image_fill_images(working_root):
|
||||
href = (
|
||||
element.get('href')
|
||||
or element.get(f'{{{XLINK_NS}}}href')
|
||||
or ''
|
||||
)
|
||||
if href.lstrip().lower().startswith('data:'):
|
||||
inline_count += 1
|
||||
continue
|
||||
filename = cls._external_image_reference_basename(href)
|
||||
if not filename:
|
||||
continue
|
||||
references.setdefault(filename, set())
|
||||
placements[filename].append((
|
||||
svg_path,
|
||||
element.get('preserveAspectRatio') or '',
|
||||
('text picture fill',),
|
||||
None,
|
||||
))
|
||||
if _resolve_external_image_reference is not None:
|
||||
resolved = _resolve_external_image_reference(
|
||||
svg_path.parent,
|
||||
href,
|
||||
)
|
||||
if resolved is not None:
|
||||
references[filename].add(resolved.resolve())
|
||||
out[svg_path] = dict(references)
|
||||
if inline_count:
|
||||
inline_counts[svg_path] = inline_count
|
||||
return out, inline_counts, dict(placements)
|
||||
|
||||
@classmethod
|
||||
def _text_image_fill_images(cls, root: ET.Element) -> List[ET.Element]:
|
||||
"""Return <image> children of text picture-fill patterns in use."""
|
||||
patterns = {
|
||||
pattern.get('id'): pattern
|
||||
for pattern in root.iter(f'{{{SVG_NS}}}pattern')
|
||||
if pattern.get('data-pptx-text-image-fill') and pattern.get('id')
|
||||
}
|
||||
if not patterns:
|
||||
return []
|
||||
used: set[str] = set()
|
||||
for element in root.iter():
|
||||
if _local_name(element) not in {'text', 'tspan'}:
|
||||
continue
|
||||
style_values = (
|
||||
_parse_inline_style(element.get('style'))
|
||||
if _parse_inline_style is not None
|
||||
else {}
|
||||
)
|
||||
fill = style_values.get('fill') or element.get('fill') or ''
|
||||
match = re.match(r'\s*url\(\s*[\'"]?#([^)\'"\s]+)', fill)
|
||||
if match and match.group(1) in patterns:
|
||||
used.add(match.group(1))
|
||||
return [
|
||||
image
|
||||
for pattern_id in used
|
||||
for image in patterns[pattern_id].iter(f'{{{SVG_NS}}}image')
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _image_frame_geometry(
|
||||
image: ET.Element,
|
||||
|
||||
+23
-3
@@ -67,7 +67,15 @@ _CHROME_ID_TOKENS = frozenset({
|
||||
'header', 'footer',
|
||||
'chrome', 'watermark',
|
||||
'pagenumber', 'pagenum', 'slidenumber', 'slidenum',
|
||||
'logo', 'nav', 'rule',
|
||||
'logo', 'nav',
|
||||
})
|
||||
# `rule` is chrome only as a bare id or a decorative line name (`rule`,
|
||||
# `rule-2`, `hairline-rule`); a content group such as `commit-rule` is not.
|
||||
_RULE_TOKENS = frozenset({'rule', 'rules'})
|
||||
_RULE_QUALIFIERS = frozenset({
|
||||
'hairline', 'thin', 'horizontal', 'vertical', 'h', 'v',
|
||||
'top', 'bottom', 'left', 'right', 'mid', 'middle',
|
||||
'section', 'page', 'title', 'header', 'footer', 'divider',
|
||||
})
|
||||
|
||||
|
||||
@@ -112,8 +120,20 @@ def is_chrome_id(elem_id: str | None) -> bool:
|
||||
compact = lower.replace('-', '').replace('_', '')
|
||||
if compact in _CHROME_ID_TOKENS:
|
||||
return True
|
||||
tokens = re.split(r'[-_]', lower)
|
||||
return any(t in _CHROME_ID_TOKENS for t in tokens if t)
|
||||
tokens = [t for t in re.split(r'[-_]', lower) if t]
|
||||
if any(t in _CHROME_ID_TOKENS for t in tokens):
|
||||
return True
|
||||
if compact in _RULE_TOKENS:
|
||||
return True
|
||||
if tokens and tokens[0] in _RULE_TOKENS and all(
|
||||
t.isdigit() or t in _RULE_QUALIFIERS for t in tokens[1:]
|
||||
):
|
||||
return True
|
||||
if tokens and tokens[-1] in _RULE_TOKENS and all(
|
||||
t in _RULE_QUALIFIERS or t.isdigit() for t in tokens[:-1]
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_TITLE_BLOCK_TOKENS = frozenset({'header', 'footer'})
|
||||
|
||||
+6
@@ -1597,6 +1597,12 @@ def _chart_data(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise RuntimeError(
|
||||
"Native PPTX chart plot_area is supported for classic charts only"
|
||||
)
|
||||
if payload.get("axes") is not None and chart_type in _CHARTEX_CHART_TYPES:
|
||||
raise RuntimeError(
|
||||
"Native PPTX chart axes are supported for classic charts only; "
|
||||
f"a ChartEx {chart_type} chart draws its own axes and shows values "
|
||||
"through companion text"
|
||||
)
|
||||
if (
|
||||
chart_type not in _CATEGORY_CHART_TYPES | {"combo", "stock"} | _XY_CHART_TYPES
|
||||
and _data_labels_config(payload) is not None
|
||||
|
||||
+25
-5
@@ -1174,24 +1174,30 @@ def _chart_projection_text_variants(value: Any) -> set[str]:
|
||||
return variants
|
||||
|
||||
|
||||
_NUMBER_FORMAT_RE = re.compile(r"^(#,##|#|0)?(0*)(?:\.(0+))?(%?)$")
|
||||
_NUMBER_FORMAT_RE = re.compile(
|
||||
r'^(?:"(?P<prefix>[^"]*)")?(#,##|#|0)?(0*)(?:\.(0+))?(%?)(?:"(?P<suffix>[^"]*)")?$'
|
||||
)
|
||||
|
||||
|
||||
def _format_number_like_excel(number: float, number_format: str) -> str | None:
|
||||
"""Render ``number`` the way PowerPoint shows a plain Excel format code.
|
||||
|
||||
Covers the codes a chart payload realistically writes — ``0``, ``0.0``,
|
||||
``0.00``, ``#,##0``, ``#,##0.0``, and their ``%`` forms. Anything else
|
||||
returns ``None`` so the caller keeps only the literal variants.
|
||||
``0.00``, ``#,##0``, ``#,##0.0``, their ``%`` forms, and a quoted literal
|
||||
prefix or suffix such as ``"$"#,##0`` or ``0.0"%"`` (a literal percent
|
||||
sign does not scale the value). Anything else returns ``None`` so the
|
||||
caller keeps only the literal variants.
|
||||
"""
|
||||
match = _NUMBER_FORMAT_RE.match(number_format.strip())
|
||||
if match is None:
|
||||
return None
|
||||
grouping, _integers, decimals, percent = match.groups()
|
||||
prefix = match.group("prefix") or ""
|
||||
suffix = match.group("suffix") or ""
|
||||
grouping, _integers, decimals, percent = match.groups()[1:5]
|
||||
value = number * 100 if percent else number
|
||||
digits = len(decimals or "")
|
||||
text = f"{value:,.{digits}f}" if grouping == "#,##" else f"{value:.{digits}f}"
|
||||
return f"{text}%" if percent else text
|
||||
return f"{prefix}{text}{'%' if percent else ''}{suffix}"
|
||||
|
||||
|
||||
def _chart_number_formats(payload: dict[str, Any]) -> list[str]:
|
||||
@@ -1480,10 +1486,21 @@ def _chart_companion_entries(
|
||||
return entries
|
||||
|
||||
|
||||
def _chart_companion_width_only(item: dict[str, Any]) -> int | None:
|
||||
"""Return the authored width when a companion carries only `width`."""
|
||||
if "width" in item and not any(key in item for key in ("x", "y", "height")):
|
||||
return _powerpoint_emu(item["width"], "companion text width", positive=True)
|
||||
return None
|
||||
|
||||
|
||||
def _chart_companion_box(item: dict[str, Any]) -> tuple[int, int, int, int] | None:
|
||||
"""Validate and resolve an optional explicit companion text box."""
|
||||
box_keys = ("x", "y", "width", "height")
|
||||
provided_box_keys = [key for key in box_keys if key in item]
|
||||
if provided_box_keys == ["width"]:
|
||||
# Width alone: the box is placed from the matching fallback text or
|
||||
# the default slot; only its width is authored.
|
||||
return None
|
||||
if provided_box_keys and len(provided_box_keys) != len(box_keys):
|
||||
raise RuntimeError(
|
||||
"Native PPTX chart companion text boxes require x/y/width/height together"
|
||||
@@ -1595,6 +1612,7 @@ def _chart_companion_shapes(
|
||||
align = str(item.get("align") or ("ctr" if role == "title" else "l"))
|
||||
bold = bool(item.get("bold", role == "title"))
|
||||
explicit_box = _chart_companion_box(item)
|
||||
width_only = _chart_companion_width_only(item)
|
||||
if explicit_box is not None:
|
||||
off_x, off_y, ext_cx, ext_cy = explicit_box
|
||||
elif role == "title":
|
||||
@@ -1611,6 +1629,8 @@ def _chart_companion_shapes(
|
||||
ext_cx = chart_ext_cx
|
||||
ext_cy = px_to_emu(16)
|
||||
below_index += 1
|
||||
if width_only is not None:
|
||||
ext_cx = width_only
|
||||
anchor = "t"
|
||||
matches = [
|
||||
record for record in fallback_texts
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPT Master - Local Server Security Tests
|
||||
|
||||
Exercise both Flask request guards without creating project files or servers.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest discover -s skills/ppt-master/scripts/tests -p test_local_server_security.py
|
||||
|
||||
Dependencies:
|
||||
flask
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from confirm_ui import server as confirm_server # noqa: E402
|
||||
from svg_editor import server as editor_server # noqa: E402
|
||||
|
||||
|
||||
class LocalServerSecurityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.servers = []
|
||||
for server in (editor_server, confirm_server):
|
||||
with patch.object(server.threading.Thread, 'start'):
|
||||
app = server.create_app(str(SCRIPTS_DIR / '__security_test_project__'), idle_timeout=0)
|
||||
app.config['TESTING'] = True
|
||||
self.servers.append((server, app, app.test_client()))
|
||||
|
||||
def test_loopback_hosts_are_accepted(self) -> None:
|
||||
for server, _, client in self.servers:
|
||||
for host in ('127.0.0.1', '127.0.0.1:6060', 'localhost', 'localhost:6060',
|
||||
'LOCALHOST:6060', '::1', '[::1]', '[::1]:6060'):
|
||||
with self.subTest(server=server.__name__, host=host):
|
||||
response = client.get('/api/health', headers={'Host': host})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_other_hosts_are_rejected_before_activity_update(self) -> None:
|
||||
for server, app, client in self.servers:
|
||||
for host in ('evil.example:6060', '127.0.0.1.evil.example', 'localhost.evil.example',
|
||||
'192.168.1.1', '[::2]:6060', '::1:6060', 'evil@localhost:6060', ''):
|
||||
with self.subTest(server=server.__name__, host=host):
|
||||
app.config['LAST_REQUEST_TIME'] = 0
|
||||
response = client.get('/api/health', headers={'Host': host})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(app.config['LAST_REQUEST_TIME'], 0)
|
||||
|
||||
def test_loopback_origins_and_missing_origin_are_accepted(self) -> None:
|
||||
for server, _, client in self.servers:
|
||||
for origin in (None, 'http://127.0.0.1:6060', 'http://localhost',
|
||||
'https://LOCALHOST:6060', 'http://[::1]', 'http://[::1]:6060'):
|
||||
with self.subTest(server=server.__name__, origin=origin):
|
||||
headers = {} if origin is None else {'Origin': origin}
|
||||
self.assertEqual(client.get('/api/health', headers=headers).status_code, 200)
|
||||
|
||||
def test_other_or_malformed_origins_are_rejected(self) -> None:
|
||||
for server, app, client in self.servers:
|
||||
for origin in ('http://evil.example', 'http://127.0.0.1.evil.example',
|
||||
'http://localhost@evil.example', 'http://[::2]',
|
||||
'http://[::1', 'null', ''):
|
||||
with self.subTest(server=server.__name__, origin=origin):
|
||||
app.config['LAST_REQUEST_TIME'] = 0
|
||||
response = client.get('/api/health', headers={'Origin': origin})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(app.config['LAST_REQUEST_TIME'], 0)
|
||||
|
||||
def test_internal_readiness_url_is_accepted(self) -> None:
|
||||
for server, _, client in self.servers:
|
||||
with self.subTest(server=server.__name__):
|
||||
response = client.get(server._server_url(6060, '/api/health'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_guard_also_blocks_shutdown_posts(self) -> None:
|
||||
for server, _, client in self.servers:
|
||||
for headers in ({'Host': 'evil.example:6060'}, {'Origin': 'http://evil.example'}):
|
||||
with self.subTest(server=server.__name__, headers=headers):
|
||||
with patch.object(server.threading.Thread, 'start') as start:
|
||||
response = client.post('/api/shutdown', json={}, headers=headers)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
start.assert_not_called()
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise MiniMax original-text subtitle spans through audio generation.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest tests.test_minimax_subtitles
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library).
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tts_backends import backend_minimax
|
||||
|
||||
|
||||
class MiniMaxSubtitleTests(unittest.TestCase):
|
||||
def generate(self, text: str, words: list[dict], directory: Path) -> None:
|
||||
response = {
|
||||
"base_resp": {"status_code": 0},
|
||||
"data": {
|
||||
"audio": b"mock-audio".hex(),
|
||||
"subtitle_file": "https://example.invalid/subtitles.json",
|
||||
},
|
||||
}
|
||||
subtitle = json.dumps([{"timestamped_words": words}]).encode("utf-8")
|
||||
with (
|
||||
patch.object(backend_minimax, "post_json", return_value=response),
|
||||
patch.object(backend_minimax, "get_bytes", return_value=subtitle),
|
||||
):
|
||||
backend_minimax.generate(
|
||||
text, directory / "page.mp3",
|
||||
api_key="test-key", voice_id="test-voice", model="speech-2.8-hd",
|
||||
audio_format="mp3", sample_rate=32000, bitrate=128000, channel=1,
|
||||
speed=1.0, volume=1.0, pitch=0, language_boost="auto", base_url=None,
|
||||
subtitle_path=directory / "page.srt",
|
||||
)
|
||||
|
||||
def test_spoken_number_parts_keep_one_original_token_and_full_duration(self):
|
||||
words = [
|
||||
{"word": "第", "word_begin": 0, "word_end": 1, "time_begin": 0, "time_end": 100},
|
||||
{"word": "42", "word_begin": 1, "word_end": 3, "time_begin": 100, "time_end": 200},
|
||||
{"word": "42", "word_begin": 1, "word_end": 3, "time_begin": 200, "time_end": 300},
|
||||
{"word": "42", "word_begin": 1, "word_end": 3, "time_begin": 300, "time_end": 450},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
self.generate("第42。", words, directory)
|
||||
self.assertEqual((directory / "page.mp3").read_bytes(), b"mock-audio")
|
||||
self.assertEqual(
|
||||
(directory / "page.srt").read_text(encoding="utf-8"),
|
||||
"1\n00:00:00,000 --> 00:00:00,450\n第42。\n",
|
||||
)
|
||||
|
||||
def test_repeated_words_at_distinct_positions_are_preserved(self):
|
||||
words = [
|
||||
{"word": "42", "word_begin": 0, "word_end": 2, "time_begin": 0, "time_end": 100},
|
||||
{"word": "42", "word_begin": 3, "word_end": 5, "time_begin": 100, "time_end": 200},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
self.generate("42,42。", words, directory)
|
||||
self.assertIn("42,42。", (directory / "page.srt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_missing_or_invalid_spans_do_not_hide_text_mismatches(self):
|
||||
for span in (None, (-1, 2), (0, 0), ("0", "2"), (False, 2)):
|
||||
with self.subTest(span=span), tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
(directory / "page.mp3").write_bytes(b"previous-audio")
|
||||
(directory / "page.srt").write_text("previous-subtitles", encoding="utf-8")
|
||||
words = [
|
||||
{"word": "42", "time_begin": 0, "time_end": 100},
|
||||
{"word": "42", "time_begin": 100, "time_end": 200},
|
||||
]
|
||||
if span is not None:
|
||||
for word in words:
|
||||
word.update(word_begin=span[0], word_end=span[1])
|
||||
with self.assertRaisesRegex(RuntimeError, "could not be aligned"):
|
||||
self.generate("42。", words, directory)
|
||||
self.assertEqual((directory / "page.mp3").read_bytes(), b"previous-audio")
|
||||
self.assertEqual(
|
||||
(directory / "page.srt").read_text(encoding="utf-8"), "previous-subtitles"
|
||||
)
|
||||
|
||||
def test_merged_number_still_rejects_backwards_word_times(self):
|
||||
words = [
|
||||
{"word": "42", "word_begin": 0, "word_end": 2, "time_begin": 0, "time_end": 100},
|
||||
{"word": "42", "word_begin": 0, "word_end": 2, "time_begin": 100, "time_end": 200},
|
||||
{"word": "42", "word_begin": 0, "word_end": 2, "time_begin": 50, "time_end": 150},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
with self.assertRaisesRegex(RuntimeError, "not in chronological order"):
|
||||
self.generate("42。", words, Path(temporary))
|
||||
+75
@@ -152,6 +152,33 @@ class ChartImportTests(unittest.TestCase):
|
||||
self.assertIn("[chart]", "".join(root.itertext()))
|
||||
|
||||
|
||||
class NumberFormatTests(unittest.TestCase):
|
||||
def test_quoted_literal_prefix_and_suffix_render_without_scaling(self) -> None:
|
||||
from svg_to_pptx.native_objects.chart_style import _format_number_like_excel
|
||||
|
||||
self.assertEqual(_format_number_like_excel(34.5, '0.0"%"'), '34.5%')
|
||||
self.assertEqual(_format_number_like_excel(0.345, '0.0%'), '34.5%')
|
||||
self.assertEqual(_format_number_like_excel(12345, '"$"#,##0'), '$12,345')
|
||||
self.assertIsNone(_format_number_like_excel(3, 'yyyy'))
|
||||
|
||||
|
||||
class ChartExPayloadGuardTests(unittest.TestCase):
|
||||
def test_chartex_refuses_a_classic_axes_block(self) -> None:
|
||||
from svg_to_pptx.native_objects.chart_data import _chart_data
|
||||
|
||||
payload = {
|
||||
"type": "waterfall",
|
||||
"categories": ["Start", "Step", "End"],
|
||||
"values": [10, 5, 15],
|
||||
"axes": {"value": {"kind": "value", "minimum": 0, "maximum": 24}},
|
||||
}
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
_chart_data(payload)
|
||||
self.assertIn("classic charts only", str(caught.exception))
|
||||
payload.pop("axes")
|
||||
self.assertEqual(_chart_data(payload)["kind"], "chartex")
|
||||
|
||||
|
||||
class CategoryOrderTests(unittest.TestCase):
|
||||
def test_bar_categories_read_top_down_with_value_axis_at_bottom(self) -> None:
|
||||
xml = _render(_bar_payload())
|
||||
@@ -385,6 +412,54 @@ class SpacingTests(unittest.TestCase):
|
||||
|
||||
|
||||
class CompanionPlacementTests(unittest.TestCase):
|
||||
def test_svg_first_companion_may_carry_only_its_width(self) -> None:
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes
|
||||
from svg_to_pptx.drawingml.utils import px_to_emu
|
||||
from svg_to_pptx.native_objects.fallback_hash import stamp_native_fallback_baseline
|
||||
|
||||
payload = {
|
||||
"x": 100, "y": 100, "width": 600, "height": 300,
|
||||
"name": "chart-note",
|
||||
"type": "column",
|
||||
"categories": ["A", "B"],
|
||||
"series": [{"name": "S", "values": [1, 2]}],
|
||||
"plot_area": {"x": 140, "y": 130, "width": 540, "height": 250},
|
||||
"style": {"colors": ["#111111"], "text_color": "#111111"},
|
||||
# Only the width is authored; x/y/height come from the fallback text.
|
||||
"notes": [{"text": "Unit note", "width": 200, "font_size": 18}],
|
||||
}
|
||||
svg = (
|
||||
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720">'
|
||||
'<g id="chart-note" data-pptx-replace-with="chart">'
|
||||
f'<metadata type="application/json">{json.dumps(payload)}</metadata>'
|
||||
'<rect x="140" y="130" width="540" height="250" fill="#FFFFFF"/>'
|
||||
'<text x="140" y="120" font-size="18" fill="#111111">Unit note</text>'
|
||||
'<text x="410" y="400" font-size="18" fill="#111111">A</text>'
|
||||
'<text x="410" y="400" font-size="18" fill="#111111">B</text>'
|
||||
"</g></svg>"
|
||||
)
|
||||
root = ET.fromstring(svg)
|
||||
stamp_native_fallback_baseline(root.find(f"{{{SVG_NS}}}g"), document_root=root)
|
||||
svg = ET.tostring(root, encoding="unicode")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "01.svg"
|
||||
path.write_text(svg, encoding="utf-8")
|
||||
slide_xml, *_ = convert_svg_to_slide_shapes(
|
||||
path, resource_root=Path(tmp), native_objects=True,
|
||||
)
|
||||
note = slide_xml[slide_xml.index("Chart Note"):]
|
||||
off_x = int(re.search(r'<a:off x="(\d+)" y="\d+"/>', note).group(1))
|
||||
off_y = int(re.search(r'<a:off x="\d+" y="(\d+)"/>', note).group(1))
|
||||
ext_cx = int(re.search(r'<a:ext cx="(\d+)" cy="\d+"/>', note).group(1))
|
||||
ext_cy = int(re.search(r'<a:ext cx="\d+" cy="(\d+)"/>', note).group(1))
|
||||
self.assertEqual(ext_cx, px_to_emu(200))
|
||||
self.assertEqual(off_x, px_to_emu(140))
|
||||
self.assertEqual(off_y + ext_cy, px_to_emu(120 + 18 * 0.25))
|
||||
|
||||
def test_svg_first_companion_sits_on_the_fallback_baseline(self) -> None:
|
||||
import json
|
||||
import re
|
||||
|
||||
+144
@@ -130,6 +130,150 @@ class SliceImagesDiagnosticsTests(unittest.TestCase):
|
||||
self.assertIn("--bg #57B265 --tolerance 12", result.stderr)
|
||||
self.assertFalse((output_dir / "element.png").exists())
|
||||
|
||||
def test_strict_alpha_names_edge_wide_near_key_noise_as_key_noise(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sheet_path = root / "sheet.png"
|
||||
output_dir = root / "output"
|
||||
# A slightly off, noisy key (as a JPEG round-trip leaves it) with
|
||||
# one element well clear of every cell edge.
|
||||
image = Image.new("RGB", (120, 120), (2, 253, 2))
|
||||
px = image.load()
|
||||
for y in range(120):
|
||||
for x in range(120):
|
||||
if (x * 7 + y * 13) % 5 == 0:
|
||||
px[x, y] = (6, 240, 8)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((40, 40, 80, 80), fill=(170, 40, 55))
|
||||
image.save(sheet_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(sheet_path),
|
||||
"--grid", "1x1",
|
||||
"--names", "element",
|
||||
"--trim",
|
||||
"--alpha",
|
||||
"--strict-alpha",
|
||||
"--bg", "#00FF00",
|
||||
"--tolerance", "12",
|
||||
"--output", str(output_dir),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("this is key noise", result.stderr)
|
||||
self.assertNotIn("content reaches the", result.stderr)
|
||||
self.assertIn("Suggested rerun:", result.stderr)
|
||||
self.assertFalse((output_dir / "element.png").exists())
|
||||
|
||||
def test_inset_accepts_horizontal_and_vertical_fractions(self) -> None:
|
||||
from slice_images import parse_inset
|
||||
|
||||
self.assertEqual(parse_inset("0.03"), (0.03, 0.03))
|
||||
self.assertEqual(parse_inset("0.01,0.03"), (0.01, 0.03))
|
||||
with self.assertRaises(ValueError):
|
||||
parse_inset("0.5")
|
||||
with self.assertRaises(ValueError):
|
||||
parse_inset("0.1,0.2,0.3")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sheet_path = root / "sheet.png"
|
||||
output_dir = root / "output"
|
||||
# Two wide bands with a white grid line between them: an
|
||||
# isotropic inset wide enough for the line would cut the glyphs.
|
||||
image = Image.new("RGB", (400, 100), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, 48, 399, 51), fill=(255, 255, 255))
|
||||
draw.rectangle((10, 8, 390, 40), fill=(240, 240, 240))
|
||||
draw.rectangle((10, 58, 390, 92), fill=(240, 240, 240))
|
||||
image.save(sheet_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, str(SCRIPT), str(sheet_path),
|
||||
"--grid", "2x1", "--names", "a,b",
|
||||
"--trim", "--alpha", "--strict-alpha",
|
||||
"--bg", "#0000FF", "--inset", "0,0.06",
|
||||
"--output", str(output_dir),
|
||||
],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(Image.open(output_dir / "a.png").size, (381, 33))
|
||||
self.assertEqual(Image.open(output_dir / "b.png").size, (381, 35))
|
||||
|
||||
def test_strict_alpha_rejects_a_sheet_whose_ground_recovers_as_haze(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sheet_path = root / "sheet.png"
|
||||
output_dir = root / "output"
|
||||
# The real ground (#034AF4) sits farther from pure blue than the
|
||||
# tolerance, so soft-alpha recovery turns the whole field into a
|
||||
# faint half-foreground; the outer gutter alone does not catch it.
|
||||
image = Image.new("RGB", (160, 120), (3, 74, 244))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((60, 40, 100, 80), fill=(250, 250, 250))
|
||||
image.save(sheet_path)
|
||||
|
||||
args = [
|
||||
sys.executable, str(SCRIPT), str(sheet_path),
|
||||
"--grid", "1x1", "--names", "mark",
|
||||
"--trim", "--alpha", "--strict-alpha",
|
||||
"--output", str(output_dir),
|
||||
]
|
||||
result = subprocess.run(
|
||||
args + ["--bg", "#0000FF", "--tolerance", "62"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("semi-transparent", result.stderr)
|
||||
self.assertIn("measured ground colour", result.stderr)
|
||||
self.assertFalse((output_dir / "mark.png").exists())
|
||||
|
||||
result = subprocess.run(
|
||||
args + ["--bg", "#034AF4", "--tolerance", "62"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(Image.open(output_dir / "mark.png").size, (41, 41))
|
||||
|
||||
def test_strict_alpha_names_painted_card_cells_instead_of_a_key_rerun(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sheet_path = root / "sheet.png"
|
||||
output_dir = root / "output"
|
||||
# The model painted each cell as a dark card and left the key only
|
||||
# as thin grid lines: after --inset the cells are all panel.
|
||||
image = Image.new("RGB", (200, 100), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((3, 3, 96, 96), fill=(12, 14, 30))
|
||||
draw.rectangle((103, 3, 196, 96), fill=(12, 14, 30))
|
||||
draw.rectangle((30, 30, 60, 60), fill=(240, 200, 120))
|
||||
draw.rectangle((130, 30, 160, 60), fill=(240, 200, 120))
|
||||
image.save(sheet_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, str(SCRIPT), str(sheet_path),
|
||||
"--grid", "1x2", "--names", "a,b",
|
||||
"--trim", "--alpha", "--strict-alpha",
|
||||
"--bg", "#0000FF", "--inset", "0.05",
|
||||
"--output", str(output_dir),
|
||||
],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("painted backing panel", result.stderr)
|
||||
self.assertIn("Cells painted as panels", result.stderr)
|
||||
self.assertNotIn("Suggested rerun:", result.stderr)
|
||||
|
||||
|
||||
class ImageOrientationProcessingTests(unittest.TestCase):
|
||||
def test_compression_applies_orientation_and_preserves_image_format(self) -> None:
|
||||
|
||||
+48
@@ -36,6 +36,54 @@ def _empty_result() -> dict:
|
||||
return {'errors': [], 'warnings': []}
|
||||
|
||||
|
||||
class LeadingInlineRunParagraphTests(unittest.TestCase):
|
||||
def test_first_line_opening_with_an_inline_run_is_a_paragraph(self) -> None:
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
|
||||
'<text x="460" y="318" font-size="18">'
|
||||
'<tspan font-weight="bold">Trigger</tspan> — already chronically absent'
|
||||
'<tspan x="460" dy="26"><tspan font-weight="bold">Action</tspan> — case review</tspan>'
|
||||
'<tspan x="460" dy="26"><tspan font-weight="bold">Owner</tspan> — coordinator</tspan>'
|
||||
'</text></svg>'
|
||||
)
|
||||
root = ET.fromstring(svg)
|
||||
text_el = next(iter(root))
|
||||
classified = checker_module._classify_paragraph_block(text_el, preserve_line_breaks=True)
|
||||
self.assertIsNotNone(classified)
|
||||
_base, _extras, _breaks, line_groups, synthetic_first = classified
|
||||
self.assertEqual(len(line_groups), 3)
|
||||
self.assertIsNotNone(synthetic_first)
|
||||
|
||||
|
||||
class TextPictureFillReferenceTests(unittest.TestCase):
|
||||
def test_text_picture_fill_pattern_image_counts_as_a_rendered_reference(self) -> None:
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
|
||||
'<defs>'
|
||||
'<pattern id="titleTexture" data-pptx-text-image-fill="stretch" '
|
||||
'patternUnits="objectBoundingBox" patternContentUnits="objectBoundingBox" '
|
||||
'width="1" height="1">'
|
||||
'<image href="../images/title_fill.png" x="0" y="0" width="1" height="1" '
|
||||
'preserveAspectRatio="none"/></pattern>'
|
||||
'<pattern id="unused" data-pptx-text-image-fill="stretch" '
|
||||
'patternUnits="objectBoundingBox" width="1" height="1">'
|
||||
'<image href="../images/unused.png" width="1" height="1"/></pattern>'
|
||||
'</defs>'
|
||||
'<text x="80" y="200" font-size="96" fill="url(#titleTexture)">Title</text>'
|
||||
'</svg>'
|
||||
)
|
||||
root = ET.fromstring(svg)
|
||||
images = SVGQualityChecker._text_image_fill_images(root)
|
||||
self.assertEqual(
|
||||
[image.get('href') for image in images],
|
||||
['../images/title_fill.png'],
|
||||
)
|
||||
# The generic visible-image scan still ignores <defs>, so the
|
||||
# pattern image is a placement only through the text fill path.
|
||||
_root, _parents, visible = SVGQualityChecker._visible_image_elements(root)
|
||||
self.assertEqual(visible, [])
|
||||
|
||||
|
||||
class SVGQualityCheckerBoundsTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _text_bounds(root: ET.Element, *, include_headroom: bool = False) -> tuple:
|
||||
|
||||
@@ -49,6 +49,41 @@ def _run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
)
|
||||
|
||||
|
||||
class CalibrateRoleWeightTests(unittest.TestCase):
|
||||
def test_role_argument_accepts_a_bold_suffix(self) -> None:
|
||||
from text_measure import _role_argument
|
||||
|
||||
self.assertEqual(_role_argument('title:Arial:36'), ('title', 'Arial', 36.0, 'normal'))
|
||||
self.assertEqual(_role_argument('title:Arial:36:bold'), ('title', 'Arial', 36.0, 'bold'))
|
||||
|
||||
def test_bold_role_calibrates_wider(self) -> None:
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from text_measure import _calibration_payload
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
normal = _calibration_payload(
|
||||
[('title', 'Arial', 36.0)], project_path=Path(tmp),
|
||||
source='--role', include_outline=False,
|
||||
)['roles']['title']
|
||||
bold = _calibration_payload(
|
||||
[('title', 'Arial', 36.0)], project_path=Path(tmp),
|
||||
source='--role', include_outline=False, weights={'title': 'bold'},
|
||||
)['roles']['title']
|
||||
self.assertEqual(bold['weight'], 'bold')
|
||||
self.assertLess(bold['latin_chars_per_100px'], normal['latin_chars_per_100px'])
|
||||
|
||||
|
||||
class WrapUnitTests(unittest.TestCase):
|
||||
def test_percent_sign_stays_with_its_number(self) -> None:
|
||||
from text_measure import wrap_text
|
||||
|
||||
lines, _widths, _oversized = wrap_text(
|
||||
'xxxx yyyy 71% of the cohort', size=26, max_width=175, family='Arial',
|
||||
)
|
||||
self.assertEqual(lines, ['xxxx yyyy', '71% of the', 'cohort'])
|
||||
|
||||
|
||||
class TextMeasureTests(unittest.TestCase):
|
||||
def test_measure_matches_checker_estimator(self) -> None:
|
||||
run = {
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPT Master - Web Conversion Security Tests
|
||||
|
||||
Check URL validation, TLS defaults, and failure paths with in-memory responses.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest discover -s skills/ppt-master/scripts/tests -p test_web_to_md_security.py
|
||||
|
||||
Dependencies:
|
||||
requests, beautifulsoup4
|
||||
"""
|
||||
|
||||
import io
|
||||
import socket
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import source_to_md # noqa: E402
|
||||
|
||||
WEB_BACKEND_DIR = SCRIPTS_DIR / 'source_to_md'
|
||||
if str(WEB_BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(WEB_BACKEND_DIR))
|
||||
|
||||
import web_to_md # noqa: E402
|
||||
|
||||
|
||||
PUBLIC_URL = 'https://8.8.8.8/page'
|
||||
|
||||
|
||||
class PublicUrlTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
dns_patch = patch.object(web_to_md.socket, 'getaddrinfo', side_effect=AssertionError('Unexpected DNS'))
|
||||
dns_patch.start()
|
||||
self.addCleanup(dns_patch.stop)
|
||||
|
||||
def test_non_public_literal_addresses_are_rejected_without_dns(self) -> None:
|
||||
for host in ('169.254.169.254', '10.0.0.1', '172.16.0.1', '192.168.0.1',
|
||||
'127.0.0.1', '0.0.0.0', '[::1]', '[::]', '[fe80::1]', '[fc00::1]',
|
||||
'[::ffff:127.0.0.1]'):
|
||||
with self.subTest(host=host), self.assertRaisesRegex(ValueError, 'non-public'):
|
||||
web_to_md._validate_public_url(f'http://{host}/')
|
||||
|
||||
def test_public_literal_addresses_are_accepted_without_dns(self) -> None:
|
||||
for host in ('8.8.8.8', '[2001:4860:4860::8888]'):
|
||||
with self.subTest(host=host):
|
||||
self.assertIsNone(web_to_md._validate_public_url(f'https://{host}/'))
|
||||
|
||||
def test_non_http_or_malformed_urls_are_rejected(self) -> None:
|
||||
for url in ('file:///etc/passwd', 'ftp://8.8.8.8/', 'data:text/plain,hello',
|
||||
'//8.8.8.8/', 'http:///missing-host', 'http://[::1'):
|
||||
with self.subTest(url=url), self.assertRaises(ValueError):
|
||||
web_to_md._validate_public_url(url)
|
||||
|
||||
def test_every_dns_answer_must_be_public(self) -> None:
|
||||
public = (socket.AF_INET, socket.SOCK_STREAM, 6, '', ('8.8.8.8', 0))
|
||||
private = (socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fe80::1', 0, 0, 0))
|
||||
with patch.object(web_to_md.socket, 'getaddrinfo', return_value=[public]) as resolve:
|
||||
self.assertIsNone(web_to_md._validate_public_url('https://page.example/'))
|
||||
resolve.assert_called_once_with('page.example', None, type=socket.SOCK_STREAM)
|
||||
with patch.object(web_to_md.socket, 'getaddrinfo', return_value=[public, private]):
|
||||
with self.assertRaisesRegex(ValueError, 'non-public'):
|
||||
web_to_md._validate_public_url('https://page.example/')
|
||||
|
||||
def test_dns_failure_or_empty_answers_fail_closed(self) -> None:
|
||||
with patch.object(web_to_md.socket, 'getaddrinfo', side_effect=socket.gaierror('unresolved')):
|
||||
with self.assertRaisesRegex(ValueError, 'Cannot validate URL hostname'):
|
||||
web_to_md._validate_public_url('https://page.example/')
|
||||
with patch.object(web_to_md.socket, 'getaddrinfo', return_value=[]):
|
||||
with self.assertRaisesRegex(ValueError, 'Cannot resolve URL hostname'):
|
||||
web_to_md._validate_public_url('https://page.example/')
|
||||
|
||||
def test_initial_url_is_checked_before_either_backend_fetches(self) -> None:
|
||||
for use_curl in (False, True):
|
||||
backend = Mock()
|
||||
with self.subTest(curl=use_curl), \
|
||||
patch.object(web_to_md, 'curl_requests', backend if use_curl else None), \
|
||||
patch.object(web_to_md.requests, 'get', backend.get):
|
||||
with self.assertRaisesRegex(ValueError, 'non-public'):
|
||||
web_to_md._http_get('http://169.254.169.254/latest/meta-data/')
|
||||
backend.get.assert_not_called()
|
||||
|
||||
def test_final_url_is_checked_and_response_closed_for_both_backends(self) -> None:
|
||||
for use_curl in (False, True):
|
||||
for final_url in ('http://10.0.0.1/private', 'file:///etc/passwd'):
|
||||
response = Mock(url=final_url)
|
||||
backend = Mock()
|
||||
backend.get.return_value = response
|
||||
with self.subTest(curl=use_curl, final_url=final_url), \
|
||||
patch.object(web_to_md, 'curl_requests', backend if use_curl else None), \
|
||||
patch.object(web_to_md.requests, 'get', backend.get):
|
||||
with self.assertRaises(ValueError):
|
||||
web_to_md._http_get(PUBLIC_URL)
|
||||
response.close.assert_called_once_with()
|
||||
|
||||
def test_unsafe_page_or_image_redirect_does_not_write_output(self) -> None:
|
||||
page = Mock(
|
||||
url=PUBLIC_URL,
|
||||
content=b'<html><head><title>Page</title></head><body>'
|
||||
b'<article><p>Content</p><img src="/image.png"></article></body></html>',
|
||||
headers={'Content-Type': 'text/html; charset=utf-8'},
|
||||
)
|
||||
for unsafe_image in (False, True):
|
||||
blocked = Mock(url='http://169.254.169.254/secret')
|
||||
responses = [page, blocked] if unsafe_image else [blocked]
|
||||
with self.subTest(image=unsafe_image), \
|
||||
patch.object(web_to_md, 'curl_requests', None), \
|
||||
patch.object(web_to_md.requests, 'get', side_effect=responses), \
|
||||
patch.object(web_to_md.os, 'makedirs'), \
|
||||
patch('builtins.open') as write, redirect_stdout(io.StringIO()):
|
||||
result = web_to_md.process_url(PUBLIC_URL, '/unused/result.md')
|
||||
self.assertFalse(result[0])
|
||||
self.assertIn('non-public', result[2])
|
||||
write.assert_not_called()
|
||||
blocked.close.assert_called_once_with()
|
||||
|
||||
|
||||
class TlsVerificationTests(unittest.TestCase):
|
||||
def test_both_http_backends_verify_by_default_and_honor_explicit_opt_out(self) -> None:
|
||||
for use_curl in (False, True):
|
||||
for kwargs, expected in (({}, True), ({'verify': False}, False)):
|
||||
backend = Mock()
|
||||
backend.get.return_value = Mock(url=PUBLIC_URL)
|
||||
with self.subTest(curl=use_curl, kwargs=kwargs), \
|
||||
patch.object(web_to_md, 'curl_requests', backend if use_curl else None), \
|
||||
patch.object(web_to_md.requests, 'get', backend.get):
|
||||
web_to_md._http_get(PUBLIC_URL, **kwargs)
|
||||
self.assertIs(backend.get.call_args.kwargs['verify'], expected)
|
||||
if use_curl:
|
||||
self.assertEqual(backend.get.call_args.kwargs['impersonate'], web_to_md._CURL_IMPERSONATE)
|
||||
|
||||
def test_pages_and_images_share_the_insecure_config(self) -> None:
|
||||
response = Mock(url=PUBLIC_URL, content=b'page', headers={'Content-Type': 'text/plain; charset=utf-8'})
|
||||
for insecure in (False, True):
|
||||
with self.subTest(insecure=insecure), patch.dict(web_to_md.CONFIG, insecure=insecure):
|
||||
with patch.object(web_to_md, '_http_get', return_value=response) as fetch:
|
||||
web_to_md.fetch_url(PUBLIC_URL)
|
||||
self.assertIs(fetch.call_args.kwargs['verify'], not insecure)
|
||||
content = web_to_md.BeautifulSoup('<img src="/image.png">', 'html.parser')
|
||||
with patch.object(web_to_md, '_http_get', side_effect=web_to_md._UnsafeUrlError('blocked')) as fetch, \
|
||||
patch.object(web_to_md.os, 'makedirs'):
|
||||
with self.assertRaises(web_to_md._UnsafeUrlError):
|
||||
web_to_md.download_and_rewrite_images(content, PUBLIC_URL, '/unused/images', 'images')
|
||||
self.assertIs(fetch.call_args.kwargs['verify'], not insecure)
|
||||
|
||||
def test_cli_insecure_is_opt_in_and_warns(self) -> None:
|
||||
with patch.dict(web_to_md.CONFIG), \
|
||||
patch.object(web_to_md, 'process_url', return_value=(True, PUBLIC_URL, None, None)), \
|
||||
redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()) as errors:
|
||||
self.assertEqual(web_to_md.main([PUBLIC_URL, '--insecure']), 0)
|
||||
self.assertIs(web_to_md.CONFIG['insecure'], True)
|
||||
self.assertIn('TLS certificate verification disabled', errors.getvalue())
|
||||
self.assertEqual(web_to_md.main([PUBLIC_URL]), 0)
|
||||
self.assertIs(web_to_md.CONFIG['insecure'], False)
|
||||
|
||||
def test_dispatcher_forwards_insecure_to_web_backend(self) -> None:
|
||||
args, unknown = source_to_md.build_parser().parse_known_args([PUBLIC_URL, '--insecure'])
|
||||
with patch.object(source_to_md, 'run_backend', return_value=1) as run:
|
||||
self.assertEqual(source_to_md.dispatch_single(PUBLIC_URL, 'web', '/unused/result.md', args, unknown), 1)
|
||||
command, script_name = run.call_args.args
|
||||
self.assertEqual(script_name, 'web_to_md.py')
|
||||
self.assertEqual(command.count('--insecure'), 1)
|
||||
@@ -44,11 +44,15 @@ _PREFERRED_BREAK_PUNCTUATION = frozenset(',。;:')
|
||||
# visibly short line, so the greedy fill wins.
|
||||
_PREFERRED_BREAK_MIN_FILL = 0.75
|
||||
_LATIN_TOKEN_CONNECTORS = frozenset("'’._:/+%@#-")
|
||||
# A unit-like sign glued to a number stays with it: never break "71" | "%".
|
||||
_NUMBER_SUFFIXES = frozenset("%‰°")
|
||||
_WEIGHTS = ('normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900')
|
||||
_CALIBRATION_CJK_SAMPLE = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往'
|
||||
_CALIBRATION_LATIN_SAMPLE = 'Clear Slides Make Big Ideas Easy to See.'
|
||||
_CALIBRATION_CAPS_SAMPLE = 'CLEAR SLIDES MAKE BIG IDEAS EASY TO SEE.'
|
||||
_CALIBRATION_DIGITS_SAMPLE = '0123456789'
|
||||
# A source line: dataset ids, years, brackets and caps mixed into prose.
|
||||
_CALIBRATION_CITATION_SAMPLE = 'NYC DOE, dataset sgsi-66kk (2018-19 to 2022-23), v2.1'
|
||||
_CORE_CALIBRATION_ROLES = ('body', 'title', 'subtitle', 'annotation')
|
||||
_SLIDE_HEADING_RE = re.compile(
|
||||
r'^#{3,6}[ \t]+Slide[ \t]+([0-9]+|NN)\b.*$',
|
||||
@@ -153,6 +157,9 @@ def _lexical_units(text: str) -> list[str]:
|
||||
):
|
||||
end += 2
|
||||
continue
|
||||
if next_cluster in _NUMBER_SUFFIXES and clusters[end - 1].isdigit():
|
||||
end += 1
|
||||
continue
|
||||
break
|
||||
|
||||
prefix = ' ' if pending_space else ''
|
||||
@@ -276,12 +283,16 @@ def text_box(
|
||||
return dict(x=left, y=top, width=width, height=bottom - top, top=top, bottom=bottom)
|
||||
|
||||
|
||||
def _role_argument(value: str) -> tuple[str, str, float]:
|
||||
def _role_argument(value: str) -> tuple[str, str, float, str]:
|
||||
weight = 'normal'
|
||||
parts = value.rsplit(':', 1)
|
||||
if len(parts) == 2 and parts[1].strip().casefold() in _ROLE_WEIGHTS:
|
||||
value, weight = parts[0], parts[1].strip().casefold()
|
||||
try:
|
||||
name_and_family, raw_size = value.rsplit(':', 1)
|
||||
name, family = name_and_family.split(':', 1)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError('expected NAME:FAMILY:SIZE') from exc
|
||||
raise argparse.ArgumentTypeError('expected NAME:FAMILY:SIZE[:bold]') from exc
|
||||
name, family = name.strip().casefold(), family.strip()
|
||||
if not name or not family:
|
||||
raise argparse.ArgumentTypeError('expected non-empty NAME and FAMILY')
|
||||
@@ -289,7 +300,10 @@ def _role_argument(value: str) -> tuple[str, str, float]:
|
||||
size = _positive_float(raw_size)
|
||||
except (argparse.ArgumentTypeError, ValueError) as exc:
|
||||
raise argparse.ArgumentTypeError('SIZE must be a positive finite number') from exc
|
||||
return name, family, size
|
||||
return name, family, size, weight
|
||||
|
||||
|
||||
_ROLE_WEIGHTS = frozenset({'normal', 'bold'})
|
||||
|
||||
|
||||
def _ordered_roles(roles: dict[str, tuple[str, float]]) -> list[tuple[str, str, float]]:
|
||||
@@ -301,6 +315,7 @@ def _ordered_roles(roles: dict[str, tuple[str, float]]) -> list[tuple[str, str,
|
||||
def _roles_from_spec_lock(
|
||||
lock_path: Path,
|
||||
fallbacks: dict[str, str] | None = None,
|
||||
weights: dict[str, str] | None = None,
|
||||
) -> dict[str, tuple[str, float]]:
|
||||
lock = parse_spec_lock(lock_path, report_duplicate_fields=True)
|
||||
typography = next(
|
||||
@@ -451,7 +466,9 @@ def _truncate_planned_line(text: str, limit: int = 40) -> str:
|
||||
def _longest_planned_lines(
|
||||
project_path: Path,
|
||||
roles: list[tuple[str, str, float]],
|
||||
weights: dict[str, str] | None = None,
|
||||
) -> dict[str, dict[str, object] | None]:
|
||||
weights = weights or {}
|
||||
candidates = _outline_candidates(
|
||||
project_path / 'design_spec.md',
|
||||
{name for name, _family, _size in roles},
|
||||
@@ -460,7 +477,10 @@ def _longest_planned_lines(
|
||||
for name, family, size in roles:
|
||||
best: tuple[float, str, str] | None = None
|
||||
for slide, planned_line in candidates[name]:
|
||||
width = measure_text(planned_line, size=size, family=family)
|
||||
width = measure_text(
|
||||
planned_line, size=size, family=family,
|
||||
weight=weights.get(name, 'normal'),
|
||||
)
|
||||
if best is None or width > best[0]:
|
||||
best = (width, slide, planned_line)
|
||||
longest[name] = None if best is None else {
|
||||
@@ -477,28 +497,36 @@ def _calibration_payload(
|
||||
project_path: Path,
|
||||
source: str,
|
||||
include_outline: bool,
|
||||
weights: dict[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
weights = weights or {}
|
||||
longest = (
|
||||
_longest_planned_lines(project_path, roles)
|
||||
_longest_planned_lines(project_path, roles, weights)
|
||||
if include_outline
|
||||
else {name: None for name, _family, _size in roles}
|
||||
)
|
||||
cjk_length = len(split_project_text_clusters(_CALIBRATION_CJK_SAMPLE))
|
||||
latin_length = len(split_project_text_clusters(_CALIBRATION_LATIN_SAMPLE))
|
||||
digits_length = len(split_project_text_clusters(_CALIBRATION_DIGITS_SAMPLE))
|
||||
citation_length = len(split_project_text_clusters(_CALIBRATION_CITATION_SAMPLE))
|
||||
role_rows = {}
|
||||
for name, family, size in roles:
|
||||
cjk_width = measure_text(_CALIBRATION_CJK_SAMPLE, size=size, family=family)
|
||||
latin_width = measure_text(_CALIBRATION_LATIN_SAMPLE, size=size, family=family)
|
||||
caps_width = measure_text(_CALIBRATION_CAPS_SAMPLE, size=size, family=family)
|
||||
digits_width = measure_text(_CALIBRATION_DIGITS_SAMPLE, size=size, family=family)
|
||||
weight = weights.get(name, 'normal')
|
||||
style = dict(size=size, family=family, weight=weight)
|
||||
cjk_width = measure_text(_CALIBRATION_CJK_SAMPLE, **style)
|
||||
latin_width = measure_text(_CALIBRATION_LATIN_SAMPLE, **style)
|
||||
caps_width = measure_text(_CALIBRATION_CAPS_SAMPLE, **style)
|
||||
digits_width = measure_text(_CALIBRATION_DIGITS_SAMPLE, **style)
|
||||
citation_width = measure_text(_CALIBRATION_CITATION_SAMPLE, **style)
|
||||
role_rows[name] = {
|
||||
'family': family,
|
||||
'size': size,
|
||||
'weight': weight,
|
||||
'cjk_chars_per_100px': round(100.0 * cjk_length / cjk_width, 1),
|
||||
'latin_chars_per_100px': round(100.0 * latin_length / latin_width, 1),
|
||||
'caps_chars_per_100px': round(100.0 * latin_length / caps_width, 1),
|
||||
'digits_chars_per_100px': round(100.0 * digits_length / digits_width, 1),
|
||||
'citation_chars_per_100px': round(100.0 * citation_length / citation_width, 1),
|
||||
'longest_planned_line': longest[name],
|
||||
}
|
||||
return {
|
||||
@@ -533,7 +561,7 @@ def _fallback_notes(
|
||||
def _render_calibration_table(payload: dict[str, object], *, include_outline: bool) -> str:
|
||||
role_rows = payload['roles']
|
||||
assert isinstance(role_rows, dict)
|
||||
headers = ['role', 'family', 'size', 'CJK ≈chars/100px', 'Latin ≈chars/100px', 'CAPS ≈chars/100px', 'DIGITS ≈chars/100px']
|
||||
headers = ['role', 'family', 'size', 'CJK ≈chars/100px', 'Latin ≈chars/100px', 'CAPS ≈chars/100px', 'DIGITS ≈chars/100px', 'CITE ≈chars/100px']
|
||||
if include_outline:
|
||||
headers.append('longest planned line (px, slide, text)')
|
||||
lines = [
|
||||
@@ -551,6 +579,7 @@ def _render_calibration_table(payload: dict[str, object], *, include_outline: bo
|
||||
f'{raw_row["latin_chars_per_100px"]:.1f}',
|
||||
f'{raw_row["caps_chars_per_100px"]:.1f}',
|
||||
f'{raw_row["digits_chars_per_100px"]:.1f}',
|
||||
f'{raw_row.get("citation_chars_per_100px", 0.0):.1f}',
|
||||
]
|
||||
if include_outline:
|
||||
planned = raw_row['longest_planned_line']
|
||||
@@ -573,12 +602,18 @@ def _render_calibration_table(payload: dict[str, object], *, include_outline: bo
|
||||
'while authoring (an expanded title, a longer label) is re-estimated '
|
||||
'with the rates — the outline column does not cover it.'
|
||||
)
|
||||
lines.append(
|
||||
'[NOTE] measure and wrap take one --weight for the whole string: a line '
|
||||
'with a bold run (an inline emphasis lead) is measured piecewise per '
|
||||
'weight and summed, or the bold clause takes its own line.'
|
||||
)
|
||||
lines.append(
|
||||
'[NOTE] rates are sample averages measured with the checker\'s '
|
||||
'estimator (headroom included); the checker measures each real line '
|
||||
'glyph by glyph, so capital-heavy words (WebGPU, GDP), digits (1935, '
|
||||
'83.2%) and wide letters run wider than the Latin rate — use the CAPS '
|
||||
'rate for acronyms and uppercase, the DIGITS rate for numbers, and keep '
|
||||
'rate for acronyms and uppercase, the DIGITS rate for numbers, the CITE '
|
||||
'rate for source lines (dataset ids, years, brackets), and keep '
|
||||
'about 5% below any bounds width.'
|
||||
)
|
||||
return '\n'.join(lines) + '\n'
|
||||
@@ -595,19 +630,21 @@ def _run_calibrate(args: argparse.Namespace) -> int:
|
||||
lock_path = project_path / 'spec_lock.md'
|
||||
if not lock_path.is_file() and not args.role:
|
||||
print(
|
||||
'Calibration requires spec_lock.md or at least one --role NAME:FAMILY:SIZE entry.',
|
||||
'Calibration requires spec_lock.md or at least one --role NAME:FAMILY:SIZE[:bold] entry.',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
try:
|
||||
fallbacks: dict[str, str] = {}
|
||||
weights: dict[str, str] = {}
|
||||
roles = (
|
||||
_roles_from_spec_lock(lock_path, fallbacks)
|
||||
_roles_from_spec_lock(lock_path, fallbacks, weights)
|
||||
if lock_path.is_file()
|
||||
else {}
|
||||
)
|
||||
for name, family, size in args.role:
|
||||
for name, family, size, weight in args.role:
|
||||
roles[name] = (family, size)
|
||||
weights[name] = weight
|
||||
fallbacks.pop(name, None)
|
||||
ordered_roles = _ordered_roles(roles)
|
||||
if not ordered_roles:
|
||||
@@ -618,8 +655,17 @@ def _run_calibrate(args: argparse.Namespace) -> int:
|
||||
project_path=project_path,
|
||||
source=source,
|
||||
include_outline=args.outline,
|
||||
weights=weights,
|
||||
)
|
||||
payload['notes'] = _fallback_notes(roles, fallbacks)
|
||||
bold_roles = sorted(name for name, weight in weights.items() if weight == 'bold')
|
||||
payload['notes'].append(
|
||||
'rates for ' + ', '.join(bold_roles) + ' are measured at bold weight'
|
||||
if bold_roles else
|
||||
'every role is measured at normal weight; recalibrate a role '
|
||||
'realized as bold deck-wide with --role NAME:FAMILY:SIZE:bold, '
|
||||
'since bold runs about 7% wider'
|
||||
)
|
||||
output_path = project_path / 'validation' / 'text_calibration.json'
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if args.role and output_path.is_file():
|
||||
@@ -695,7 +741,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
action='append',
|
||||
type=_role_argument,
|
||||
default=[],
|
||||
metavar='NAME:FAMILY:SIZE',
|
||||
metavar='NAME:FAMILY:SIZE[:bold]',
|
||||
help='Typography role to calibrate when spec_lock.md is absent, e.g. '
|
||||
'body:"Microsoft YaHei":20; repeatable.',
|
||||
)
|
||||
|
||||
+24
-1
@@ -63,12 +63,14 @@ def _word_boundaries(raw: object) -> list[dict]:
|
||||
raise RuntimeError("MiniMax subtitle response contains no sentence blocks")
|
||||
|
||||
boundaries: list[dict] = []
|
||||
previous_start = -1
|
||||
for sentence in raw:
|
||||
if not isinstance(sentence, dict):
|
||||
raise RuntimeError("MiniMax subtitle sentence block is not an object")
|
||||
words = sentence.get("timestamped_words")
|
||||
if not isinstance(words, list) or not words:
|
||||
raise RuntimeError("MiniMax subtitle sentence block contains no word timings")
|
||||
previous_span = None
|
||||
for item in words:
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError("MiniMax subtitle word timing is not an object")
|
||||
@@ -81,8 +83,29 @@ def _word_boundaries(raw: object) -> list[dict]:
|
||||
raise RuntimeError(
|
||||
"MiniMax subtitle response contains an invalid word interval"
|
||||
)
|
||||
if boundaries and start < boundaries[-1]["offset"]:
|
||||
if start < previous_start:
|
||||
raise RuntimeError("MiniMax subtitle words are not in chronological order")
|
||||
previous_start = start
|
||||
word_begin, word_end = item.get("word_begin"), item.get("word_end")
|
||||
span = (
|
||||
(word_begin, word_end)
|
||||
if type(word_begin) is int
|
||||
and type(word_end) is int
|
||||
and 0 <= word_begin < word_end
|
||||
else None
|
||||
)
|
||||
# Normalized numbers can emit one row per spoken syllable, all
|
||||
# pointing at the same original-text span (e.g. "42" three times).
|
||||
# The span is the merge key; the text match is a guard — a same-span
|
||||
# row with a different word falls through and the shared alignment
|
||||
# check reports it instead of merging it silently.
|
||||
if span is not None and span == previous_span and boundaries[-1]["text"] == word:
|
||||
previous = boundaries[-1]
|
||||
previous["duration"] = max(
|
||||
previous["duration"], end - previous["offset"]
|
||||
)
|
||||
continue
|
||||
previous_span = span
|
||||
boundaries.append(
|
||||
{
|
||||
"text": word,
|
||||
|
||||
@@ -64,18 +64,22 @@ def _candidate_directory(value: str) -> Path | None:
|
||||
return None
|
||||
try:
|
||||
raw_path = Path(value)
|
||||
# A long positional string (a paragraph handed to text_measure.py)
|
||||
# is not a path; stat-ing it raises ENAMETOOLONG on most filesystems.
|
||||
if any(len(part.encode("utf-8", "surrogateescape")) > 255 for part in raw_path.parts):
|
||||
return None
|
||||
if not raw_path.exists() and raw_path.parent == Path("."):
|
||||
return None
|
||||
path = raw_path
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
while not path.exists() and path != path.parent:
|
||||
path = path.parent
|
||||
if path.is_file():
|
||||
path = path.parent
|
||||
return path.resolve()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not raw_path.exists() and raw_path.parent == Path("."):
|
||||
return None
|
||||
path = raw_path
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
while not path.exists() and path != path.parent:
|
||||
path = path.parent
|
||||
if path.is_file():
|
||||
path = path.parent
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _find_project_root(argv: list[str]) -> Path | None:
|
||||
|
||||
+57
-57
@@ -1,11 +1,11 @@
|
||||
---
|
||||
style_id: duty-manual
|
||||
kind: style
|
||||
summary: 把散落规则收敛到一条判断主轴上的技术手册方法,暗场栅格、机制先行、每条规则可追回其机制页
|
||||
keywords: [技术手册, 暗场栅格, 机制先行, 判断主轴, 线稿图解]
|
||||
summary: Technical-manual method that gathers scattered rules onto one judgment axis — dark-field grid, mechanism before rule, every rule traceable to its mechanism page.
|
||||
keywords: [technical-manual, dark-field-grid, mechanism-first, judgment-axis, line-drawing]
|
||||
---
|
||||
|
||||
# 勤务手册 — Style Specification
|
||||
# Duty Manual — Style Specification
|
||||
|
||||
> Method and design defaults only. No project communication contract, brand identity, page structure, or SVG prototypes.
|
||||
|
||||
@@ -13,97 +13,97 @@ keywords: [技术手册, 暗场栅格, 机制先行, 判断主轴, 线稿图解]
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Style Name | 勤务手册 |
|
||||
| Best Fit | 需要把散落在多份材料里的规则、规范、操作条目收敛到一条可推导主轴上的技术材料、训练课件、作业规范讲解与内部技术分享;受众要能据此在现场自行判断,而不是背条文 |
|
||||
| Reusable Intent | 标题写成可判断的句子;机制先于规则;每一条规则都能追回它所依据的机制页;读者随时知道自己在主轴的哪个位置 |
|
||||
| Sources | 从一份已完成的技术分享材料提炼的方法与视觉默认;基底参考 instructional / pyramid(方法)、swiss-minimal / blueprint / dark-tech(视觉)、blueprint / vector-illustration / digital-dashboard(图像) |
|
||||
| Style Name | Duty Manual (勤务手册) |
|
||||
| Best Fit | Technical material, training courseware, operating-standard walkthroughs, and internal technical sharing that must gather rules, standards, and procedure items scattered across many documents onto one derivable axis; the audience must be able to judge on site from it rather than recite clauses |
|
||||
| Reusable Intent | Titles written as judgeable sentences; mechanism before rule; every rule traceable to the mechanism page it rests on; the reader always knows where on the axis they stand |
|
||||
| Sources | Method and visual defaults distilled from one finished technical-sharing deck; base references instructional / pyramid (method), swiss-minimal / blueprint / dark-tech (visual), blueprint / vector-illustration / digital-dashboard (image) |
|
||||
|
||||
## II. Communication Method
|
||||
|
||||
- **Preferred Mode**: custom
|
||||
- **Mode References**: instructional, pyramid
|
||||
- **Mode Behavior**: 每一段开头先给判断句结论,再以「分解—展开—应用」三拍讲透机制:先给这一条的定义与代价,再给触发变化的条件,最后落到它所需的配置、器材与动作。全篇维持一条纵轴,任何一页都能回答「现在在哪一级、哪一步」。
|
||||
- **Argument Flow**: 先用可外部核验的规模事实确立「为什么值得重讲一次」,再分两段建立认知(对象的客观性质、对象的时间性),第三段给出贯穿全篇的判断主轴,其后各段是这条主轴的展开与保障,末段把规则收回到已讲过的机制。段落之间以「这条机制决定了下一段的顺序」这类因果句衔接,不用「接下来我们看」这类过场语。
|
||||
- **Page Message Discipline**: 一页一个可判断的主张,写在标题里;副标题承担该主张的一句话理由;正文只提供支撑这一主张的证据与限制。标题写成判断句而非名词短语——「入水是最后的手段」而不是「入水救援」。
|
||||
- **Claim Discipline**: 规则不以条文形式独立出现,必须先有承载它的机制页;引用规则时标出它追回的机制页码。凡是外部事实都写明来源与口径年份;本材料自建的框架、分级或归类要标明是整理而非来源原文。相互冲突的表述并列呈现并说明取舍依据,不静默择一。
|
||||
- **Mode Behavior**: Open every section with a judgment-sentence conclusion, then explain the mechanism in three beats — break down, expand, apply: first the item's definition and cost, then the conditions that trigger change, finally the configuration, equipment, and actions it requires. Keep one vertical axis through the whole deck so any page can answer "which level, which step are we at now".
|
||||
- **Argument Flow**: First establish "why this deserves retelling" with externally verifiable scale facts, then build understanding in two sections (the object's objective nature, the object's behavior over time), then give the judgment axis that runs through the whole deck in the third section; later sections expand and safeguard that axis, and the closing section folds the rules back onto the mechanisms already taught. Link sections with causal sentences such as "this mechanism decides the order of the next section", never with filler transitions like "next let us look at".
|
||||
- **Page Message Discipline**: One judgeable claim per page, written in the title; the subtitle carries that claim's one-sentence reason; the body supplies only the evidence and limits that support the claim. Write titles as judgment sentences rather than noun phrases — "Entering the water is the last resort", not "In-water rescue".
|
||||
- **Claim Discipline**: A rule never appears as a free-standing clause; the mechanism page that carries it must come first, and any citation of a rule marks the mechanism page it traces to. Every external fact names its source and reference year; frameworks, gradings, or classifications built for this material are marked as compiled rather than quoted from a source. Conflicting statements are presented side by side with the basis for the choice, never silently resolved.
|
||||
|
||||
## III. Page Role Vocabulary
|
||||
|
||||
| Role | Communication Job | Evidence Obligation | Composition Tendency |
|
||||
|---|---|---|---|
|
||||
| 冲击开篇 | 用规模事实确立议题值得重讲 | 每个数字带来源与口径年份;不采用无出处的流传数字 | 少量大字号数字占据主区,其余大面积留白,底部一句翻转认知的结论 |
|
||||
| 段落章节页 | 标出进入哪一段、本段要回答什么 | 列出本段条目但不承诺顺序与页数 | 满版影像压深色蒙版,超大段落编号与判断句并置 |
|
||||
| 机制页 | 讲透一条机制,使后续规则可被推导 | 因果链完整;给出行动含义而不只是现象 | 图解与文字左右或上下分置,图解占近半幅,文字给判断句与含义 |
|
||||
| 主轴总览 | 一次性给出贯穿全篇的判断顺序 | 每一级的定义,以及随级递增的两条代价 | 主视觉占半幅,另半幅为分级索引与国际对应框架 |
|
||||
| 轴上分级页 | 讲清这一级的定义、代价与升级触发 | 适用条件、禁忌、升级触发三者齐全 | 标题旁与页面左缘同时标出当前位置 |
|
||||
| 技术展开页 | 展开一项具体做法并标明它属于主轴哪一级 | 注意要点与适用范围成对出现 | 图解与要点分置,页面右上以标牌标出所属级别 |
|
||||
| 清单表页 | 让读者逐项对照 | 每项写出它挡住的风险或支撑的级别,而非只有名称 | 表格占主区,前提项以底色区分,整体形象图置于侧栏 |
|
||||
| 岗位配置表页 | 说明岗位、人数与职责 | 人数门槛与岗位职责成对出现,门槛写明是成立条件 | 表格加人数徽记,关键岗位以文字色强调 |
|
||||
| 程序序列页 | 给出不可调换的步骤顺序 | 每步的核心动作与它为何不能后置 | 序列纵向排列,实景影像锚定其中一步 |
|
||||
| 反例页 | 指认一个越出主轴的常见做法并拆解其失败机制 | 至少两条机制加一次对前文数据的回指,并给出替代做法 | 单一主视觉压禁止标记,左缘指示条整体转为警示色 |
|
||||
| 收束规范页 | 把规则收回到已讲过的机制 | 每条规则可追回其机制页;标出反复回到的那几条 | 多列密排,回指项以强调色标出 |
|
||||
| Impact opener | Establish with scale facts that the topic deserves retelling | Every number carries a source and reference year; no unsourced folklore figures | A few large-size numbers occupy the main zone with generous white space elsewhere; one perception-flipping conclusion at the bottom |
|
||||
| Section divider | Mark which section begins and what it answers | List the section's items without promising order or page count | Full-bleed image under a dark scrim; an oversized section number beside the judgment sentence |
|
||||
| Mechanism page | Explain one mechanism fully so later rules can be derived | Complete causal chain; state the implication for action, not only the phenomenon | Diagram and text split left/right or top/bottom, the diagram near half the page, the text giving the judgment sentence and its implication |
|
||||
| Axis overview | Give the judgment order that runs through the deck in one view | Each level's definition plus the two costs that rise with level | Main visual on one half; on the other half the level index and the corresponding international framework |
|
||||
| Level page | Explain one level's definition, cost, and escalation trigger | Applicability, contraindications, and escalation trigger all present | Current position marked both beside the title and on the page's left edge |
|
||||
| Technique page | Expand one concrete practice and mark which axis level it belongs to | Cautions and scope of application appear as a pair | Diagram and key points split; a plate at the top right names the level |
|
||||
| Checklist table page | Let the reader verify item by item | Each item states the risk it blocks or the level it supports, not only its name | Table in the main zone, prerequisite rows tinted, an overall image in the side column |
|
||||
| Staffing table page | State posts, headcount, and responsibilities | Headcount thresholds and post duties appear as pairs; thresholds are stated as conditions of validity | Table with headcount badges; key posts emphasized through text color |
|
||||
| Procedure sequence page | Give a step order that cannot be reshuffled | Each step's core action and why it cannot come later | Steps stacked vertically, a real photograph anchoring one of them |
|
||||
| Counterexample page | Name one common practice that leaves the axis and dissect why it fails | At least two mechanisms plus one callback to earlier data, with the replacement practice | A single main visual under a prohibition mark; the left-edge indicator bar turns wholly to the warning color |
|
||||
| Closing standards page | Fold the rules back onto the mechanisms already taught | Every rule traces to its mechanism page; the few mechanisms returned to repeatedly are marked | Dense multi-column layout, callback items in the emphasis color |
|
||||
|
||||
## IV. Evidence & Data Expression
|
||||
|
||||
- **Argument Trace**: 规范条目页与机制页之间保持双向可追:机制页在页脚写出它支撑哪条规则,规范页在条目旁标出它追回的机制页码。读者可以从任一端走到另一端。
|
||||
- **Charts**: 量级差异悬殊的序列不要压进同一坐标轴——把可比的序列画进图,把量级远大的那一项拆成独立数值块并注明倍数关系,信息不丢而图仍可读。数据点按刻度精确计算,不目测。非线性关系在标题里直接说出结论(「翻一倍,翻四倍」)。
|
||||
- **Tables**: 用横向行线而非满格线;表头以底色与字重区分,关键行用文字色而非底色强调,避免与表头混淆。数值右对齐、单位一致。深色底材料的原生表格必须显式声明单元格底色,否则导出后会变成白底浅字。
|
||||
- **Sources**: 数据页页脚写来源名与口径年份;同一指标存在多个口径时并列写出并说明采用哪一个。流传广但查无出处的数字宁可不用,并在备注里说明为何不用。
|
||||
- **Native Editability**: 清单、岗位配置、对照矩阵这类会被下游修改的表格优先做成原生可编辑对象;纯装饰性的微型图示不必如此。数据对象的可见回退与元数据同时书写,保持一致。
|
||||
- **Argument Trace**: Keep standards pages and mechanism pages traceable both ways: a mechanism page's footer names the rule it supports, and a standards page marks the mechanism page each item traces to. The reader can walk from either end to the other.
|
||||
- **Charts**: Never force series of wildly different magnitude onto one axis — chart the comparable series, pull the far larger item into a separate numeric block with its multiple stated, so nothing is lost and the chart stays readable. Compute data points exactly from the scale, never by eye. State the conclusion of a non-linear relationship directly in the title ("double it, and it quadruples").
|
||||
- **Tables**: Horizontal row rules rather than full grids; distinguish the header by fill and weight, emphasize key rows through text color rather than fill so they are not confused with the header. Right-align numbers and keep units consistent. A native table on a dark deck must declare its cell fills explicitly, or it exports as light text on a white ground.
|
||||
- **Sources**: Data pages carry the source name and reference year in the footer; when one indicator has several definitions, list them together and say which is used. A widely circulated figure with no traceable source is left out, with a note explaining why.
|
||||
- **Native Editability**: Tables that downstream readers will edit — checklists, staffing tables, comparison matrices — are native editable objects first; purely decorative micro-diagrams need not be. Write a data object's visible fallback and its metadata together and keep them consistent.
|
||||
|
||||
## V. Visual System Defaults
|
||||
|
||||
- **Preferred Visual Style**: custom
|
||||
- **Visual Style References**: swiss-minimal, blueprint, dark-tech
|
||||
- **Visual Style Behavior**: 暗场上的严格栅格手册。swiss-minimal 负责基准栏、左对齐硬边与大留白;blueprint 负责发丝网格、引出线标注与技术剖面式的图解排版;dark-tech 负责暗场上的荧光强调与几何精度。容器一律细描边直角方框,圆角为零,不用投影与柔光。页面左缘固定一条分段进度指示条,当前段以强调色点亮,越界内容整条转为警示色。
|
||||
- **Composition**: 内容区以左上起手:左缘指示条先回答「现在在哪」,标题横贯上部给出判断句,证据与图解向右下展开,页面底部收一句可执行结论。图文分置在相邻页之间左右交替,形成阅读节奏。
|
||||
- **Density**: 机制页与表格页密排,段落章节页与反例页留白;每一段之内至少有一页明显低密度,作为呼吸。密度服从信息重量,不为填满版面而增加条目。
|
||||
- **Decoration**: 装饰即标注——发丝网格、引出线、刻度、指示条本身就是信息载体。不使用与信息无关的图形点缀、渐变色块与柔光。
|
||||
- **Color Behavior**: 大面积暗场底,结构线与次级标题用一个冷色,两个强调色分工固定——一个承担关键量与正确做法,另一个承担危险、禁止与最高风险级,两者不互换、不并用于同一语义。强调色只出现在关键数字与结论词上,不铺面。
|
||||
- **Typography Character**: 中性无衬线承担全部结构文本,让判断句本身产生力度;数字与级别标记用一款极重的无衬线取得重量对比;表格与图表数值用等线字保证数字对齐。标题不使用装饰字体与文字特效。
|
||||
- **Visual Style Behavior**: A strict-grid manual on a dark field. swiss-minimal owns the baseline columns, hard left-aligned edges, and generous space; blueprint owns the hairline grid, leader-line callouts, and cross-section-style diagram layout; dark-tech owns the fluorescent emphasis and geometric precision on the dark ground. Containers are hairline-stroked square boxes with zero corner radius and no drop shadow or glow. A section progress indicator bar sits fixed on the page's left edge, the current section lit in the emphasis color; on off-axis content the whole bar turns to the warning color.
|
||||
- **Composition**: The content zone starts at the top left: the left-edge indicator answers "where are we" first, the title runs across the top with the judgment sentence, evidence and diagrams unfold toward the bottom right, and the page closes with one actionable conclusion at the bottom. Image and text swap sides between adjacent pages to give the reading a rhythm.
|
||||
- **Density**: Mechanism and table pages are dense; section dividers and counterexample pages breathe; every section holds at least one visibly low-density page as a rest. Density follows information weight; never add items to fill a page.
|
||||
- **Decoration**: Decoration is annotation — the hairline grid, leader lines, tick marks, and the indicator bar are themselves information carriers. No ornament unrelated to the information, no gradient blocks, no soft glow.
|
||||
- **Color Behavior**: A large dark-field ground; structure lines and secondary headings in one cool color; two accents with a fixed division of labor — one carries key quantities and correct practice, the other carries danger, prohibition, and the highest risk level — never swapped and never used together for one meaning. Accents appear only on key numbers and conclusion words, never as fills across areas.
|
||||
- **Typography Character**: A neutral sans-serif carries all structural text so the judgment sentences supply the force themselves; numbers and level marks use one very heavy sans-serif for weight contrast; table and chart values use a monospaced-figure face so digits align. Titles use no display faces and no text effects.
|
||||
|
||||
### Fallback Color Scheme
|
||||
|
||||
| Role | HEX | Purpose |
|
||||
|---|---|---|
|
||||
| background | #0E1B2E | 全篇暗场底 |
|
||||
| secondary_bg | #1A2C46 | 面板、表头、图解衬底 |
|
||||
| primary | #4A9FD8 | 结构线、次级标题、图表主序列 |
|
||||
| accent | #D8F04A | 关键量、当前级别、正确做法 |
|
||||
| secondary_accent | #E85D2B | 危险、禁止、最高风险级 |
|
||||
| body_text | #DCE6F0 | 正文与主要标签 |
|
||||
| secondary_text | #8FA3BC | 图注、脚注、来源行 |
|
||||
| divider | #2A3F5C | 分隔线与容器描边 |
|
||||
| background | #0E1B2E | Dark field across the deck |
|
||||
| secondary_bg | #1A2C46 | Panels, table headers, diagram backing |
|
||||
| primary | #4A9FD8 | Structure lines, secondary headings, main chart series |
|
||||
| accent | #D8F04A | Key quantities, current level, correct practice |
|
||||
| secondary_accent | #E85D2B | Danger, prohibition, highest risk level |
|
||||
| body_text | #DCE6F0 | Body text and main labels |
|
||||
| secondary_text | #8FA3BC | Captions, footnotes, source lines |
|
||||
| divider | #2A3F5C | Rules and container strokes |
|
||||
|
||||
### Fallback Typography
|
||||
|
||||
| Role | Primary | Fallback Tail | Character |
|
||||
|---|---|---|---|
|
||||
| title | Microsoft YaHei | Arial, sans-serif | 中性无衬线,方正精确,由判断句本身承担力度 |
|
||||
| body | Microsoft YaHei | Arial, sans-serif | 与标题同族,长段机制说明不疲劳 |
|
||||
| display | Arial Black | sans-serif | 极重无衬线,承担段落编号、级别标记与冲击数字 |
|
||||
| data | Arial | Microsoft YaHei, sans-serif | 等线数字,表格与图表内数值等宽等高 |
|
||||
| title | Microsoft YaHei | Arial, sans-serif | Neutral sans-serif, square and precise; the judgment sentence carries the force |
|
||||
| body | Microsoft YaHei | Arial, sans-serif | Same family as the title; long mechanism passages read without fatigue |
|
||||
| display | Arial Black | sans-serif | Very heavy sans-serif for section numbers, level marks, and impact numbers |
|
||||
| data | Arial | Microsoft YaHei, sans-serif | Lining figures; table and chart values align in width and height |
|
||||
|
||||
供给的 Brand 或 Deck 身份会整体替换以上兜底配色与字体,方法与版面规则不受影响。
|
||||
A supplied Brand or Deck identity replaces this fallback palette and typography wholesale; the method and layout rules are unaffected.
|
||||
|
||||
## VI. Image & Icon Direction
|
||||
|
||||
- **Preferred Image Rendering**: custom
|
||||
- **Image Rendering References**: blueprint, vector-illustration, digital-dashboard
|
||||
- **Image Rendering Behavior**: 实底上的等宽白线技术线稿。blueprint 负责单线、引出线与剖面式取景;vector-illustration 负责干净的平面主体与无渐变填色;digital-dashboard 负责荧光标注色与刻度式数值呈现。主体以均匀细线勾勒不作填色,流动以平行流线与箭头表示,只用两个强调色分别承担关键量与危险,无渐变、无阴影、无景深、图内无文字。
|
||||
- **Image Usage**: 机制类页面由线稿图解承担机制本身,实景影像承担证据与场景锚定,两者不互相替代——讲清原理用图解,证明确有其事用实拍。段落章节页用满版实景压蒙版建立段落边界。
|
||||
- **Image Treatment**: 实景一律硬边直角嵌入,不做羽化、圆形裁切与倾斜;需要文字压在影像上时用方向性蒙版而非整幅均匀压暗。同一批线稿图解保持同一渲染,新增图必须能与既有图并排而看不出批次差异——这条优先于单张图的表现力。
|
||||
- **Icon Treatment**: 细线描边图标,与线稿图解同族;只用于分类与状态提示,不承担机制解释;投影与打印场景线宽不低于 2。
|
||||
- **Image Rendering Behavior**: Uniform-weight white technical line drawings on a solid ground. blueprint owns the single line, leader lines, and cross-section framing; vector-illustration owns clean flat subjects and gradient-free fills; digital-dashboard owns the fluorescent annotation colors and scale-style numeric display. Subjects are outlined in even thin lines without fill, flow is shown with parallel streamlines and arrows, only the two accents are used — one for key quantities, one for danger — with no gradient, no shadow, no depth of field, and no text inside the image.
|
||||
- **Image Usage**: Mechanism pages carry the mechanism through line diagrams; real photographs carry evidence and scene anchoring; neither replaces the other — explain a principle with a diagram, prove it happened with a photograph. Section dividers use a full-bleed photograph under a scrim to set the section boundary.
|
||||
- **Image Treatment**: Photographs are always inset hard-edged and square-cornered, never feathered, circle-cropped, or tilted; where text must sit over an image use a directional scrim rather than dimming the whole frame evenly. One batch of line diagrams keeps one rendering; a new diagram must sit beside the existing ones without a visible batch difference — this outranks the expressiveness of any single image.
|
||||
- **Icon Treatment**: Thin-stroke icons of the same family as the line diagrams; used only for category and status cues, never to explain a mechanism; stroke width no thinner than 2 for projection and print.
|
||||
|
||||
## VII. Review Focus
|
||||
|
||||
<!-- visual-review-trigger: explicit-user-only -->
|
||||
> Apply only after the user explicitly activates visual review. It never triggers that stage.
|
||||
|
||||
- 标题是否为可判断的句子,而不是名词短语
|
||||
- 每条规则条目是否能追回它所依据的机制页
|
||||
- 左缘进度指示条的当前位置是否与页面内容一致,越界内容是否已转为警示色
|
||||
- 两个强调色的语义分工是否被守住,是否出现互换或同页混用
|
||||
- 深色底的原生表格是否显式声明了单元格底色
|
||||
- 量级悬殊的数据是否被压进同一坐标轴而导致小量级序列不可读
|
||||
- 新增线稿图解与既有图解并排时是否存在批次差异
|
||||
- Titles are judgeable sentences rather than noun phrases
|
||||
- Every rule item traces back to the mechanism page it rests on
|
||||
- The left-edge progress indicator's current position matches the page content, and off-axis content has turned it to the warning color
|
||||
- The two accents keep their semantic division, with no swap and no mixing on one page
|
||||
- Native tables on the dark ground declare their cell fills explicitly
|
||||
- Data of very different magnitude is not forced onto one axis so that the small series becomes unreadable
|
||||
- A new line diagram placed beside the existing ones shows no batch difference
|
||||
+6
-6
@@ -30,13 +30,13 @@
|
||||
]
|
||||
},
|
||||
"duty-manual": {
|
||||
"summary": "把散落规则收敛到一条判断主轴上的技术手册方法,暗场栅格、机制先行、每条规则可追回其机制页",
|
||||
"summary": "Technical-manual method that gathers scattered rules onto one judgment axis — dark-field grid, mechanism before rule, every rule traceable to its mechanism page.",
|
||||
"keywords": [
|
||||
"技术手册",
|
||||
"暗场栅格",
|
||||
"机制先行",
|
||||
"判断主轴",
|
||||
"线稿图解"
|
||||
"technical-manual",
|
||||
"dark-field-grid",
|
||||
"mechanism-first",
|
||||
"judgment-axis",
|
||||
"line-drawing"
|
||||
]
|
||||
},
|
||||
"incident-postmortem": {
|
||||
|
||||
@@ -63,7 +63,7 @@ python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <s
|
||||
|
||||
**Workflow log**: initialization creates `<project_path>/validation/workflow.log`; later project-scoped Python tools record their command envelopes there automatically (prefix `PPT_MASTER_PROJECT_PATH="<project_path>"` when a helper's arguments do not identify the project). Append one concise note with `python3 ${SKILL_DIR}/scripts/workflow_log.py <project_path> "<detail>"` only for a material handoff, rework reason, approved exception, or manual recovery with no owning command output. The log is cold audit evidence, never read during generation.
|
||||
|
||||
**Import rules**: pass the source path once when Step 1 wrote Markdown beside it, both locations when `-o` wrote it elsewhere, and only the research pair when Topic Research ran (its facts JSON is imported as a file; no URL is fetched). Copy/move semantics, bitmap archiving, and the PPTX intake bundle it writes under `analysis/` (`<stem>.identity.json`, `<stem>.slide_library.json`, `source_profile.json`) are [`project.md`](../scripts/docs/project.md). Those artifacts are source facts and recommendation candidates, not replica constraints; Beautify stays single-deck.
|
||||
**Import rules**: pass the source path together with the Markdown Step 1 wrote beside it (a same-stem Markdown suppresses re-conversion and is imported with its sidecar and assets; the source alone is archived and converted again), both locations when `-o` wrote it elsewhere, and only the research pair when Topic Research ran (its facts JSON is imported as a file; no URL is fetched). Copy/move semantics, bitmap archiving, and the PPTX intake bundle it writes under `analysis/` (`<stem>.identity.json`, `<stem>.slide_library.json`, `source_profile.json`) are [`project.md`](../scripts/docs/project.md). Those artifacts are source facts and recommendation candidates, not replica constraints; Beautify stays single-deck.
|
||||
|
||||
**✅ Checkpoint** — project created, `sources/` complete, converted materials ready. `import-sources` exits 0 when any input converts: read the printed `skipped` reasons and treat those inputs as absent. Proceed to Step 3.
|
||||
|
||||
@@ -196,7 +196,7 @@ Read ${SKILL_DIR}/references/image-base.md # always
|
||||
| Row | Additional reference | Run |
|
||||
|---|---|---|
|
||||
| Prepared derivative | `image-generator.md` §4.4 only for registered layers | after its canonical source is terminal: `python3 ${SKILL_DIR}/scripts/image_treat.py ...` for blur, desaturation/grayscale, duotone, brightness, contrast, or `--fit WxH` downscaling to the planned size, or the §4.4 preparation path |
|
||||
| `ai` | `image-generator.md` | write `images/image_prompts.json`, render `image_prompts.md` with `image_gen.py --render-md`, then follow §7 Path Selection — `image_gen.py --manifest` is Path A only, `host-native` is Path B and skips `--manifest`, `manual` writes prompts and stops; the recorded `design_spec.md §I` path wins over `IMAGE_BACKEND` |
|
||||
| `ai` | `image-generator.md` | write `images/image_prompts.json`, render `image_prompts.md` with `image_gen.py --render-md images/image_prompts.json`, then follow §7 Path Selection — `image_gen.py --manifest` is Path A only, `host-native` is Path B and skips `--manifest`, `manual` writes prompts and stops; the recorded `design_spec.md §I` path wins over `IMAGE_BACKEND` |
|
||||
| `web` | `image-searcher.md` | `python3 ${SKILL_DIR}/scripts/image_search.py ...`; with ≥2 rows write `images/image_queries.json` and run `--batch` once |
|
||||
| `slice` | `image-generator.md` §4.3 | after the parent sheet is `Generated`: `python3 ${SKILL_DIR}/scripts/slice_images.py <project_path>/images/<sheet>.png --grid RxC --names ... --trim --alpha --bg KEY_HEX_FROM_PROMPT --strict-alpha` |
|
||||
| `user` / `placeholder` | — | skip |
|
||||
@@ -259,7 +259,7 @@ Read the core as one batch with the exact detail files named by the retained `sp
|
||||
python3 ${SKILL_DIR}/scripts/svg_editor/server.py <project_path> --live --daemon
|
||||
```
|
||||
|
||||
Default first free port from `6060` (`--port N` binds strictly); read the URL from output or `<project_path>/live_preview/lock.json` and report it — or the launch failure — before the first SVG. It is a side process: never wait for it or for user confirmation, and keep it running until the user clicks **Exit preview** or asks in chat. Do not read or apply submitted annotations during generation; that window opens after Step 7 ([`live-preview.md`](stages/live-preview.md), which also describes staged direct edits).
|
||||
Default first free port from `6060` (`--port N` binds strictly); read the URL from output or `<project_path>/live_preview/lock.json` and report it — or the launch failure, or that the user or run instructions forbade starting it — before the first SVG. It is a side process: never wait for it or for user confirmation, and keep it running until the user clicks **Exit preview** or asks in chat. Do not read or apply submitted annotations during generation; that window opens after Step 7 ([`live-preview.md`](stages/live-preview.md), which also describes staged direct edits).
|
||||
|
||||
**Cadence (Mandatory)**: P01–P05 → early gate (a planned roster of six or fewer pages skips it) → remaining pages → final gate, in one context. Every checker invocation follows one of two events: a gate point whose covered pages all exist, or the end of one consolidated repair pass. A run with neither predecessor is a pacing violation; validating an authoring pattern early is not a reason, because the same issues surface at the gate and are fixed in the same pass. Reload under Context validity above after context invalidation.
|
||||
|
||||
@@ -279,7 +279,7 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> \
|
||||
--canonical-authoring --stage early --json
|
||||
```
|
||||
|
||||
`--json` writes the report file (`validation/svg_quality_early_report.json`); stdout stays the human-readable summary and is never parsed as JSON. The stage checks every authored page so far under the partial-roster rules. Repair under the consolidated-pass discipline in [`executor-base.md`](../references/executor-base.md) §3; a still-failing verification is the next batch. If terminal output is truncated, read only the issue arrays from `validation/svg_quality_early_report.json`. The gate validates the method, not just the pages — emit one line before editing:
|
||||
`--json` writes the report file (`validation/svg_quality_early_report.json`); stdout stays the human-readable summary and is never parsed as JSON. The stage checks every authored page so far under the partial-roster rules. Repair under the consolidated-pass discipline in [`executor-base.md`](../references/executor-base.md) §3; a still-failing verification is the next batch. If terminal output is truncated, extract only `categories.blocking.issues` (and `categories.introduced.issues` when needed) from `validation/svg_quality_early_report.json`. The gate validates the method, not just the pages — emit one line before editing (in the conversation, not to a file):
|
||||
|
||||
```
|
||||
gate-signal: method=<rule resolved, or none> | page-local=<count> | not-exercised=<list>
|
||||
|
||||
+2
-2
@@ -44,7 +44,7 @@ Read [`templates/README.md`](../../templates/README.md), then only the README fo
|
||||
A Layout created with `mirror` stays eligible only when its source is brand-neutral and application-neutral; keep a branded or application-bearing source as a Deck or re-author it through `standard` / `fidelity`. Before mapping any workspace, run the shared package validator from its root — Brand/Style are roster-free, the active structure validates its roster, a shadowed Deck still validates its contract; any error blocks installation:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/svg_quality_checker.py "<workspace_root>/templates" --template-mode --canonical-authoring
|
||||
python3 ${SKILL_DIR}/scripts/svg_quality_checker.py "<workspace_root>/templates" --template-mode --canonical-authoring
|
||||
```
|
||||
|
||||
## 3. Structured Preflight
|
||||
@@ -71,7 +71,7 @@ Validate each normalized root once. The effective structural owner is Layout whe
|
||||
**Command**: the mapping, provenance line, asset copy, collision and duplicate-kind refusal, and completion receipt below are one run of the install tool (add `--dry-run` to review the mapping first; `--skip-validation` only when the §2 checker already ran on that root in this turn); [`template-tools.md`](../../scripts/docs/template-tools.md#apply_templatepy) owns its behavior:
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/apply_template.py <project_path> --root <workspace_root> [--root <workspace_root> ...]
|
||||
python3 ${SKILL_DIR}/scripts/apply_template.py <project_path> --root <workspace_root> [--root <workspace_root> ...]
|
||||
```
|
||||
|
||||
**Atomic install preflight**: resolve every source and destination path; enumerate the union mapping across all roots and across `templates/`, `images/`, `icons/`, mapping each source file at most once; resolve Layout-over-Deck precedence before building the map so the shadowed roster never enters it; reject every destination collision and duplicate kind before writing; write the accepted mapping once — never recursive copy as an implicit conflict policy. An input equal to the target project is consumed in place; if a selected Layout supersedes its in-place Deck roster, stage the mapping and replace the roster atomically.
|
||||
|
||||
+3
-3
@@ -38,7 +38,7 @@ Before editing `animations.json`, read every semantic file that exists — `desi
|
||||
| Sidecar with no new instruction | Validate and preserve unchanged; repair an invalid sidecar/group reference before export |
|
||||
| Ambiguous request | Default asks regenerate-or-modify; Quick decides from the request, SVG, and sidecar |
|
||||
|
||||
Unless an all-motion disable bypasses it, validate an existing sidecar first: `python3 skills/ppt-master/scripts/animation_config.py validate <project_path>`.
|
||||
Unless an all-motion disable bypasses it, validate an existing sidecar first: `python3 ${SKILL_DIR}/scripts/animation_config.py validate <project_path>`.
|
||||
|
||||
**Hard rule**: semantic files determine motion intent and unit boundaries; the current `svg_output/*.svg` supplies visible content and implementation structure, and its existing `<g>` hierarchy is never accepted as the plan merely because it exists.
|
||||
|
||||
@@ -103,13 +103,13 @@ Only after visual transition, lifecycle, effect, order, and timing are complete,
|
||||
|
||||
## 5. Validate and Return to Generate Export
|
||||
|
||||
When `animations.json` was created or changed after §1, run `python3 skills/ppt-master/scripts/animation_config.py validate <project_path>`, then return to the owning export path — Default [`generate-pptx.md`](../generate-pptx.md) Step 7.1; Quick [`quick-generate.md`](../profiles/quick-generate.md) §4 — both of which read the sidecar automatically. If §2 changed `svg_output/`, complete the owning route's final SVG rerun before returning; never finalize or export from this stage.
|
||||
When `animations.json` was created or changed after §1, run `python3 ${SKILL_DIR}/scripts/animation_config.py validate <project_path>`, then return to the owning export path — Default [`generate-pptx.md`](../generate-pptx.md) Step 7.1; Quick [`quick-generate.md`](../profiles/quick-generate.md) §4 — both of which read the sidecar automatically. If §2 changed `svg_output/`, complete the owning route's final SVG rerun before returning; never finalize or export from this stage.
|
||||
|
||||
**Validation**: every invalid field, target, or Morph pair fails rather than falling back ([`pptx-animations.md`](../../scripts/docs/pptx-animations.md) §6). A passing sidecar and read-back prove the PPTX configuration, not an exported MP4 audio track.
|
||||
|
||||
### 5.1 Optional Video Motion Handoff
|
||||
|
||||
When a downstream renderer will enhance the deck, have Step 7.3 append `--conversion-trace`, then derive the plan from the final resolved trace (the `--recorded-narration` trace for narrated output): `python3 skills/ppt-master/scripts/video_motion_plan.py <project_path>/validation/<output_stem>.trace.json -o <project_path>/validation/video_motion_plan.json --style adaptive --force`. The plan locks identity, effect, direction, order, bounds, and timing and may refine renderer parameters only ([`video-motion-plan.md`](../../scripts/docs/video-motion-plan.md)). With resolved sound cues on the native-export branch, the same final trace and narrated PPTX feed `video_sound_mix.py`; an explicit slideshow capture records native cue playback and uses no trace for mixing.
|
||||
When a downstream renderer will enhance the deck, have Step 7.3 append `--conversion-trace`, then derive the plan from the final resolved trace (the `--recorded-narration` trace for narrated output): `python3 ${SKILL_DIR}/scripts/video_motion_plan.py <project_path>/validation/<output_stem>.trace.json -o <project_path>/validation/video_motion_plan.json --style adaptive --force`. The plan locks identity, effect, direction, order, bounds, and timing and may refine renderer parameters only ([`video-motion-plan.md`](../../scripts/docs/video-motion-plan.md)). With resolved sound cues on the native-export branch, the same final trace and narrated PPTX feed `video_sound_mix.py`; an explicit slideshow capture records native cue playback and uses no trace for mixing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@ Default bundles only genuinely missing scope or research-boundary decisions into
|
||||
|
||||
## Step 2: Gather factual sources
|
||||
|
||||
Use the search and fetch tools available in the research context; an isolated worker without them returns `blocked: web-tools-unavailable`. With no usable search/fetch context, pause and ask the user for authoritative URLs covering the gaps, then fetch each with `web_to_md.py <URL> -o projects/<research_slug>_web_sources/<source_slug>.md --no-images` (remote image links stay in the Markdown; nothing is downloaded).
|
||||
Use the search and fetch tools available in the research context; an isolated worker without them returns `blocked: web-tools-unavailable`. With no usable search/fetch context, pause and ask the user for authoritative URLs covering the gaps, then fetch each with `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py <URL> -o projects/<research_slug>_web_sources/<source_slug>.md --no-images` (remote image links stay in the Markdown; nothing is downloaded).
|
||||
|
||||
Orient (map authoritative sources to the gaps) → deep fetch (read the highest-signal primary pages in full) → targeted fill (search only for gaps still unsupported). Prefer primary sources, official sites, institutional releases, standards, and original research; then authoritative reference works and academic sources; then reputable reporting; avoid unsourced reposts, unverifiable summaries, and stock-aggregator pages.
|
||||
Orient (map authoritative sources to the gaps) → deep fetch (read the highest-signal primary pages in full) → targeted fill (search only for gaps still unsupported). Prefer primary sources (an encyclopedia page is a pointer — chase its claim one level to the source it cites and record that source), official sites, institutional releases, standards, and original research; then authoritative reference works and academic sources; then reputable reporting; avoid unsourced reposts, unverifiable summaries, and stock-aggregator pages.
|
||||
|
||||
**Adopted webpage boundary**: record a URL only in the matching fact's `source_url`, and only when it materially supports that fact — never because its images may be useful, and never from unopened search results or a separate image-search pass. Stop when every declared gap has enough sourced evidence for the content owner to decide inclusion; do not add overview/history/outlook sections to look complete.
|
||||
|
||||
@@ -92,7 +92,7 @@ python3 ${SKILL_DIR}/scripts/project_manager.py import-sources projects/<project
|
||||
|
||||
If planning later exposes a required gap, return here and repair the pair before continuing; Strategist or Quick never consumes a newly fetched claim without updating it. The imported pair is the compact evidence-facing content authority, not a locked contract: Default's Strategist reads both files completely before confirmation; Quick's agent does the same before its content, design, and resource decisions.
|
||||
|
||||
**Single-page image fallback**: only after normal web-image providers, ranked thumbnail pages, and materially different queries fail may an image owner with visual capability select one relevant `source_url` from the facts JSON and fetch that one page package with `web_to_md.py "<source_url>" -o <project_path>/sources/<source_slug>.md`, review the companion `<source_slug>_files/` package, and copy only accepted images into `<project_path>/images/` — never pass the URL to `import-sources`, which would promote every companion image into the pool. Fetch another page only after the current package has no usable image; without vision, retain `Needs-Manual`.
|
||||
**Single-page image fallback**: only after normal web-image providers, ranked thumbnail pages, and materially different queries fail may an image owner with visual capability select one relevant `source_url` from the facts JSON and fetch that one page package with `python3 ${SKILL_DIR}/scripts/source_to_md/web_to_md.py "<source_url>" -o <project_path>/sources/<source_slug>.md`, review the companion `<source_slug>_files/` package, and copy only accepted images into `<project_path>/images/` — never pass the URL to `import-sources`, which would promote every companion image into the pool. Fetch another page only after the current package has no usable image; without vision, retain `Needs-Manual`.
|
||||
|
||||
```markdown
|
||||
## ✅ Topic Research Complete
|
||||
|
||||
+2
-2
@@ -55,13 +55,13 @@ If the list is empty, output `verify-charts: active profile declares no data-dri
|
||||
5. Run the matching command (see the [calculator documentation](../../scripts/docs/svg-pipeline.md#svg_position_calculatorpy) and recipes for decomposable, partial, formula, and manual modes):
|
||||
|
||||
```bash
|
||||
python3 skills/ppt-master/scripts/svg_position_calculator.py calc bar --data "L1:V1,L2:V2" --area "x_min,y_min,x_max,y_max" --bar-width 120 --value-range=0,axis_max
|
||||
python3 skills/ppt-master/scripts/svg_position_calculator.py calc bar --data "L1:V1,L2:V2" --area "x_min,y_min,x_max,y_max" --bar-width 120 --value-range=0,axis_max # a Native-ready bar/column passes --gap-width <payload gap_width, default 150> instead of --bar-width so the category axis follows the PowerPoint slot model the parity gate checks
|
||||
python3 skills/ppt-master/scripts/svg_position_calculator.py calc line --data "x1:y1,x2:y2" --area "x_min,y_min,x_max,y_max" --y-range=0,max
|
||||
python3 skills/ppt-master/scripts/svg_position_calculator.py calc pie --data "S1:V1,S2:V2" --center "cx,cy" --radius 200 [--inner-radius 120] --start-angle -90
|
||||
python3 skills/ppt-master/scripts/svg_position_calculator.py calc radar --data "D1:V1,D2:V2,D3:V3" --center "cx,cy" --radius 200 --max-value 100
|
||||
```
|
||||
|
||||
6. **Scale-aware comparison**: before declaring a mismatch, confirm every invocation used the axis range, plot area, center/radius, start angle, or size scale the SVG visually declares — for `calc bar` the header must show `Value scale: axis ticks (...)` when the SVG has ticks; `auto (max*1.1)` means go back to step 3. Never update the SVG with mismatched-scale output. When the scale matches and coordinates genuinely differ, update by hand — no regex or bulk replacement.
|
||||
6. **Scale-aware comparison**: before declaring a mismatch, confirm every invocation used the axis range, plot area, center/radius, start angle, or size scale the SVG visually declares — for `calc bar` the header must show `Value scale: axis ticks (...)` when the SVG has ticks; `auto (max*1.1)` means go back to step 3. Never update the SVG with mismatched-scale output. When the scale matches and coordinates genuinely differ, update by hand — no regex or bulk replacement. On a signed bar chart the calculator starts every bar at the axis minimum, so compare `x_min + width` against the drawn far edge from the zero baseline rather than its `X`.
|
||||
|
||||
After updating any page, follow the profile's checker order. Default reruns `svg_quality_checker.py <project_path> --canonical-authoring --stage final --json`, which writes the current `final` report Step 7.3 requires. Quick completes every chart comparison/repair first, then returns to `quick-generate.md` §4 for its one lockless final checker — no checker call between chart pages.
|
||||
|
||||
|
||||
@@ -41,6 +41,35 @@ An AI skill that provides design intelligence for building professional UI/UX ac
|
||||
<a href="https://nextlevelbuilder.io">NextLevelBuilder.io</a> | <a href="https://goclaw.sh">GoClaw.sh</a> | <a href="https://agentkit.best">AgentKit.best</a> | <a href="https://tose.sh">TOSE.sh</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<span>Check Out Our New Skill:</span>
|
||||
<br/>
|
||||
<a href="https://github.com/viettranx/3dviz-pro-max" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://cdn.nextlevelbuilder.io/skills/3dviz/wordmark-dark.svg">
|
||||
<img src="https://cdn.nextlevelbuilder.io/skills/3dviz/wordmark.svg" alt="3Dviz Pro Max" height="56">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center"><b>Turn an idea into a 3D scene worth exploring.</b></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://cdn.nextlevelbuilder.io/skills/3dviz/harness-village.gif" width="800" alt="Harness Village: a fantasy village with camera navigation and animated creatures">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><b>Visual inspiration, not a benchmark.</b> An author-supplied project recorded <i>before</i> this skill existed; its UI contains Vietnamese. Historical footage, not an English demo or a runtime test of the skill — see <a href="https://github.com/viettranx/3dviz-pro-max/blob/main/docs/demos/README.md">media provenance</a>.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🤌 Website: <a href="https://3dviz.dev/" target="_blank">https://3dviz.dev/</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What's New in v2.0
|
||||
|
||||
### Intelligent Design System Generation
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ui-ux-pro-max",
|
||||
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
|
||||
"ref": "main",
|
||||
"commit": "4aad0584d92131626b16d4ff4d77f0455385013c",
|
||||
"commit": "7f69fed6a2717900085f1bc3b263721f8ba025e2",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": ".claude/skills/ui-ux-pro-max",
|
||||
"syncedAt": "2026-09-06T15:59:59Z"
|
||||
"syncedAt": "2026-09-10T23:40:59Z"
|
||||
}
|
||||
Reference in new issue
Block a user