# 云书阁主题包规范（.omnitheme）

- 格式版本：`formatVersion = 1`
- 状态：草案，待实现
- 适用：用户自制主题的本地导入。不包含在线主题广场、上传或分享功能。

---

## 1. 已确定的产品决策

| 决策     | 结论                                                                                                           |
| -------- | -------------------------------------------------------------------------------------------------------------- |
| 分发方式 | 仅本地导入（文件 App、AirDrop、分享菜单、Wi-Fi 传输），App 不托管、不分发主题                                  |
| 权限     | Pro 功能，复用 `ProFeature.premiumThemes`（「主题外观」）                                                      |
| 覆盖范围 | 主题颜色、Tab 图标、应用背景、听书页与有声书页的播放组件图标和背景、阅读页的神评与本章说角标（标签式或印章式） |
| 同步     | 已导入主题随「数据备份」与「iCloud 同步」一起备份和恢复                                                        |
| 命名     | 主题名称不得与内置主题或其他已导入主题重名（同一 `id` 的升级除外）                                             |
| 校验     | 导入前完整校验，逐条指出问题所在的文件与字段；存在致命错误时拒绝导入                                           |
| 不覆盖   | 阅读正文配色（`NovelReaderTheme`）、App 图标；开屏页只换背景图与配色，「云书阁」标题与标语不可替换             |

不覆盖的元素在自定义主题下统一回退为「系统原生」风格，也就是 `AppThemeFamily.systemNative` 的无插画外观。

---

## 2. 文件格式

| 项     | 值                                                                                                                                                                                        |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 扩展名 | `.omnitheme`                                                                                                                                                                              |
| 容器   | 标准 ZIP（Deflate 或 Store），不支持加密与分卷                                                                                                                                            |
| UTType | `com.gangz1o.OmniRead.theme`，在 `UTExportedTypeDeclarations` 中声明，遵循 `public.data`、`public.content`（不声明 `public.archive`，否则「文件」App 点按时会就地解压，而不是交给云书阁） |
| 编码   | 文件名与 JSON 一律 UTF-8                                                                                                                                                                  |
| 根目录 | 压缩包根目录必须直接包含 `manifest.json`。如果根目录只有一个文件夹，导入器会自动下探一层（兼容 macOS「压缩」生成的包）                                                                    |

### 2.1 目录结构

除 `manifest.json` 外，所有文件都是**可选的**。导入器按固定路径查找素材，不需要在 manifest 里登记。

```text
我的主题.omnitheme
├── manifest.json                  必需
├── preview.png                    主题列表中的预览图
├── background/
│   ├── app.jpg                    全局背景（全屏铺满）
│   ├── atmosphere.png             书架氛围插画（透明底，右下角淡显）
│   ├── read-aloud.jpg             听书页背景
│   ├── audiobook.jpg              有声书页背景
│   └── launch.jpg                 开屏页背景
├── tab/
│   ├── bookshelf.png              书架
│   ├── bookshelf-selected.png
│   ├── audiobook.png / -selected.png
│   ├── comic.png / -selected.png
│   ├── favorites.png / -selected.png
│   ├── book-source.png / -selected.png
│   ├── collection.png / -selected.png
│   ├── search.png                 搜索只有一个状态
│   └── media-cursor.png           书架小说 / 漫画 / 有声书切换下的光标
└── player/
    ├── playback.png               播放键底座（两个播放页共用）
    ├── thumb.png                  进度滑块
    ├── timer.png                  定时
    ├── speed.png                  语速
    ├── catalog.png                目录
    ├── read-aloud/
    │   ├── voice.png              音色（听书页独有）
    │   └── *.png                  同名文件覆盖共用图标
    └── audiobook/
        ├── skip.png               跳过片头片尾（有声书页独有）
        ├── reload.png             重新拉取（有声书页独有）
        └── *.png                  同名文件覆盖共用图标
├── card/
│   └── accent.png                 书架状态卡片（阅读中 / 正在收听）上方的装饰
└── comment/
    ├── god.png                    神评角标
    ├── chapter.png                本章说角标
    └── bubble.svg                 段评数字气泡（SVG，语法同「自定义气泡」）
```

`player/mini-capsule.png`（迷你播放器胶囊皮肤）也放在 `player/` 下。

### 2.2 暗色变体

任何图片都可以额外提供暗色版本，命名规则是在扩展名前加 `-dark`，例如 `background/app-dark.jpg`、`tab/search-dark.png`、`player/playback-dark.png`。

- 有暗色变体时，暗色模式直接使用它，不再做透明度处理。
- 没有暗色变体时，按各槽位的「暗色回退」规则处理（见第 5 节）。

### 2.3 文件类型

| 用途                                                    | 允许格式                            |
| ------------------------------------------------------- | ----------------------------------- |
| 图标（`tab/`、`player/`）、氛围插画、预览图             | PNG，需要透明通道                   |
| 全屏背景（`background/app`、`read-aloud`、`audiobook`） | PNG、JPEG（`.jpg` / `.jpeg`）、HEIC |

同一槽位出现多个格式时，按 `png → jpg → jpeg → heic` 取第一个，并给出警告。

---

## 3. manifest.json

### 3.1 顶层字段

| 字段            | 类型   | 必需 | 说明                                                                                                   |
| --------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------ |
| `formatVersion` | Int    | 是   | 当前为 `1`。比 App 支持的版本高时拒绝导入，并提示「请更新云书阁」                                      |
| `id`            | String | 是   | 反向域名风格的唯一标识，匹配 `^[a-z0-9]+(\.[a-z0-9-]+){1,5}$`，长度 ≤ 100，例如 `com.example.warm-sun` |
| `name`          | String | 是   | 显示名称，1–20 字                                                                                      |
| `author`        | String | 否   | 作者，≤ 30 字                                                                                          |
| `version`       | String | 是   | 语义化版本 `主.次.修订`，用于判断重复导入时是升级还是降级                                              |
| `description`   | String | 否   | 简介，≤ 80 字                                                                                          |
| `minAppVersion` | String | 否   | 最低 App 版本，例如 `1.4.0`。当前 App 版本更低时拒绝导入                                               |
| `palette`       | Object | 是   | 颜色，见 3.2                                                                                           |
| `player`        | Object | 否   | 播放组件参数，见 3.4                                                                                   |
| `background`    | Object | 否   | 背景参数，见 3.5                                                                                       |
| `comment`       | Object | 否   | 角标样式，见 3.6                                                                                       |

未知字段一律忽略，不报错，为后续版本留出前向兼容。

### 3.2 palette

```json
"palette": {
  "light": { ... },   // 必需
  "dark":  { ... },   // 可选
  "flatSurfaces": false
}
```

- `light` 必需。
- `dark` 可选。缺失时以「经典书阁」暗色（`AppColor.dark`）为底，只把 `brandAccent` 系列换成亮色方案的强调色，并提亮到满足对比度要求。
- `flatSurfaces` 对应 `AppColorPalette.usesFlatSurfaces`。设为 `true` 时背景和卡片不使用渐变，默认 `false`。

颜色值格式：`#RRGGBB` 或 `#RRGGBBAA`，大小写不敏感。其他写法（`rgb()`、颜色名、3 位简写）都视为无效。

### 3.3 颜色 token

每个模式（`light` / `dark`）下，**只有 4 个种子色是必填的**，其余字段都可以省略，由 App 推导。作者需要精确控制时可以逐项覆盖。

| JSON 键                | 对应代码              | 必需   | 用途                           | 推导规则（省略时）                                                                   |
| ---------------------- | --------------------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------ |
| `backgroundPrimary`    | `backgroundPrimary`   | **是** | 页面底色                       | —                                                                                    |
| `surfacePrimary`       | `surfacePrimary`      | **是** | 列表、面板表面                 | —                                                                                    |
| `brandAccent`          | `brandAccent`         | **是** | 强调色：按钮填充、图标、选中态 | —                                                                                    |
| `textPrimary`          | `textPrimary`         | **是** | 主文字                         | —                                                                                    |
| `backgroundSecondary`  | `backgroundSecondary` | 否     | 背景渐变第二段                 | backgroundPrimary 向 textPrimary 混合 3%                                             |
| `surfaceSecondary`     | `surfaceSecondary`    | 否     | 次级表面、卡片渐变终点         | surfacePrimary 向 brandAccent 混合 5%                                                |
| `cardBackground`       | `cardBackground`      | 否     | 卡片底色                       | = surfacePrimary                                                                     |
| `cardHover`            | `cardHover`           | 否     | 卡片按下态                     | surfaceSecondary 向 textPrimary 混合 4%                                              |
| `brandAccentLight`     | `brandAccentLight`    | 否     | 强调色浅阶                     | brandAccent 向白色混合 25%                                                           |
| `brandAccentDark`      | `brandAccentDark`     | 否     | 强调色深阶                     | brandAccent 向黑色混合 18%                                                           |
| `onBrandAccent`        | `onBrandAccent`       | 否     | 强调色填充上的文字和图标       | 白色与 `#1F1F1F` 中，和 brandAccent 对比度更高的那个                                 |
| `brandText`            | `brandText`           | 否     | 17pt 以下的品牌色文字          | 从 brandAccent 出发，亮色模式逐步加深、暗色模式逐步提亮，直到满足 4.5:1（见第 4 节） |
| `secondaryAccent`      | `libraryBlue`         | 否     | 第二强调色                     | = brandAccent（自动换色相容易撞出难看的配色，宁可单色）                              |
| `secondaryAccentLight` | `libraryBlueLight`    | 否     | 第二强调色浅阶                 | secondaryAccent 向白色混合 30%                                                       |
| `ink`                  | `premiumInk`          | 否     | 深墨色：阴影、封面占位         | textPrimary 向黑色混合 30%                                                           |
| `textSecondary`        | `textSecondary`       | 否     | 次要文字                       | textPrimary 向 backgroundPrimary 混合 40%，再校正到 4.5:1                            |
| `textTertiary`         | `textTertiary`        | 否     | 占位符、禁用、箭头、装饰       | textPrimary 向 backgroundPrimary 混合 60%                                            |
| `borderSubtle`         | `borderSubtle`        | 否     | 细分隔线                       | backgroundPrimary 向 textPrimary 混合 10%                                            |
| `borderStrong`         | `borderStrong`        | 否     | 强分隔线                       | backgroundPrimary 向 textPrimary 混合 18%                                            |
| `success`              | `success`             | 否     | 成功状态                       | 经典书阁同模式的值                                                                   |
| `warning`              | `warning`             | 否     | 警告状态                       | 经典书阁同模式的值                                                                   |
| `error`                | `error`               | 否     | 错误状态                       | 经典书阁同模式的值                                                                   |
| `paperBeige`           | `paperBeige`          | 否     | 纸张色调表面                   | = surfaceSecondary                                                                   |

说明：

- `libraryBlue` 在现有主题里早就不一定是蓝色了（蜡笔小新主题里是黄色），所以 JSON 键改用语义化的 `secondaryAccent`，由导入器映射回代码字段。`premiumInk` 同理映射为 `ink`。
- 混合一律使用 `Color.mix(with:by:in: .device)`，与现有 `chapterUpdateBackground` 的写法保持一致。
- 推导是按顺序进行的，后面的 token 可以依赖前面已经确定的值。

### 3.4 player

```json
"player": {
  "playbackGlyph": {
    "color": "#FFFFFF",
    "colorDark": "#FFFFFF",
    "offsetY": 5.5
  },
  "thumbWidth": 30
}
```

| 字段                      | 类型         | 默认                     | 范围     | 说明                                                                                       |
| ------------------------- | ------------ | ------------------------ | -------- | ------------------------------------------------------------------------------------------ |
| `playbackGlyph.color`     | 颜色         | 亮色方案的 `brandAccent` | —        | 播放键底座上播放、暂停、加载符号的颜色                                                     |
| `playbackGlyph.colorDark` | 颜色         | = `color`                | —        | 暗色模式下的符号颜色                                                                       |
| `playbackGlyph.offsetY`   | Number（pt） | `0`                      | −12 … 12 | 符号的纵向偏移。底座上方如果有天线、铃铛之类的装饰，要把符号往下移到表盘中心，正值表示向下 |
| `thumbWidth`              | Number（pt） | `30`                     | 24 … 40  | 进度滑块图的最大宽度，高度上限固定为 30pt，按原图比例缩放                                  |

这些参数只在提供了 `player/playback.png` 或 `player/thumb.png` 时生效。

现有内置主题的取值可供参考：时光漫游、元气放映 `offsetY = 5.5`，暖日拾光 `offsetY = 1`、`thumbWidth = 36`。

### 3.5 background

```json
"background": {
  "app": { "overlayOpacity": 0.3, "blurRadius": 0 },
  "darkFallbackOpacity": 0.16
}
```

| 字段                  | 类型   | 默认   | 范围       | 说明                                                                                  |
| --------------------- | ------ | ------ | ---------- | ------------------------------------------------------------------------------------- |
| `app.overlayOpacity`  | Number | `0.3`  | 0 … 0.8    | 全局背景上叠加的底色遮罩，保证文字可读，语义同 `AppBackgroundSettings.overlayOpacity` |
| `app.blurRadius`      | Number | `0`    | 0 … 30     | 全局背景的模糊半径，语义同 `AppBackgroundSettings.blurRadius`                         |
| `darkFallbackOpacity` | Number | `0.16` | 0.05 … 0.6 | 没有 `-dark` 变体时，背景图在暗色模式下的不透明度                                     |

### 3.6 comment

```json
"comment": { "style": "label" }
```

| 值              | 含义                                                                           |
| --------------- | ------------------------------------------------------------------------------ |
| `label`（默认） | 标签式：图里自带「神评」「本章说」字样，与插画主题相同；App 不再另写「本章说」 |
| `stamp`         | 印章式：纯图标，与默认印章相同；App 在本章说图标旁写「本章说」三个字           |

样式只作用于实际提供了图片的角标；缺少的那张保持默认印章。

---

## 4. 可读性校验与自动校正

导入时按 `docs/ui/UI_STANDARD.md` 的要求计算 WCAG 对比度，亮色、暗色两套分别计算。

| 前景                | 背景                                              | 最低对比度                    |
| ------------------- | ------------------------------------------------- | ----------------------------- |
| textPrimary         | backgroundPrimary、surfacePrimary、cardBackground | 4.5                           |
| textSecondary       | backgroundPrimary、surfacePrimary、cardBackground | 4.5                           |
| brandText           | backgroundPrimary、surfacePrimary、cardBackground | 4.5                           |
| onBrandAccent       | brandAccent                                       | 3.0（按钮上的图标和粗体文字） |
| playbackGlyph.color | —（压在图片上，无法计算）                         | 不校验                        |

处理方式：

- **不因对比度拒绝导入。** 不达标的 token 会被自动沿明度方向调整：亮色模式加深，暗色模式提亮，每步 2%，直到达标，最多调整 40 步。
- 导入预览页会列出被调整的项，例如「已调整 2 处颜色以保证文字清晰：次要文字（亮色）、品牌文字（暗色）」。
- `textTertiary` 不参与校验，它只用于占位符、禁用态和装饰，与现有规范一致。
- 校正后的最终色板会写入本地缓存（见第 7 节），运行时不再重复计算。

---

## 5. 图片槽位

规范里的 **pt** 是 App 内的渲染尺寸，**建议像素** 按 @3x 给出。导入时会把超出建议尺寸的图等比降采样，小于建议尺寸的图照常接受，但显示会发虚。

### 5.1 背景

| 路径                    | 显示方式                                                                               | 建议像素                    | 暗色回退                                 | 缺失时                                 |
| ----------------------- | -------------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------- | -------------------------------------- |
| `background/app`        | 全屏 scaledToFill 居中裁切，叠加 `app.overlayOpacity` 遮罩                             | 1290 × 2796                 | 按 `darkFallbackOpacity` 淡显            | 使用 palette 的背景渐变                |
| `background/atmosphere` | 右下角淡显插画，宽度为屏宽的 82%（限制在 320–560pt 之间）；亮色 22%、暗色 16% 不透明度 | 1024 × 1536，透明底         | 同左侧说明，不再额外处理                 | 不显示                                 |
| `background/read-aloud` | 听书页全屏 scaledToFill                                                                | 1290 × 2796                 | 按 `darkFallbackOpacity` 淡显            | 借用 `audiobook`，再退到 palette 底色  |
| `background/audiobook`  | 有声书页全屏 scaledToFill                                                              | 1290 × 2796                 | 按 `darkFallbackOpacity` 淡显            | 借用 `read-aloud`，再退到 palette 底色 |
| `background/launch`     | 开屏页：竖屏铺满、底部对齐，带轻微浮动；中部标题区与底部进度区叠渐变保护               | 1290 × 2796，主体放在下半部 | 叠 52% 底色压暗（有 `-dark` 版时不压暗） | 纯底色加漂浮装饰                       |

同时提供 `app` 和 `atmosphere` 时，`atmosphere` 叠加在 `app` 之上。

**优先级**：用户在「自定义全局背景」里设置的图片 > 主题包的 `background/app` > palette 渐变。这和现有 `AppThemeCanvasBackground` 的优先级一致，两个播放页也遵守同样的规则。

### 5.2 Tab 图标

| 文件名        | 对应 `AppTab`                                 | 渲染尺寸  | 建议像素 |
| ------------- | --------------------------------------------- | --------- | -------- |
| `bookshelf`   | `.novel`（书架）                              | 25 × 25pt | 75 × 75  |
| `audiobook`   | `.audiobook`                                  | 25 × 25pt | 75 × 75  |
| `comic`       | `.comic`                                      | 25 × 25pt | 75 × 75  |
| `favorites`   | `.favorites`（收藏）                          | 25 × 25pt | 75 × 75  |
| `book-source` | `.bookSource`（书源）                         | 25 × 25pt | 75 × 75  |
| `collection`  | `.collection`（藏书阁）                       | 25 × 25pt | 75 × 75  |
| `search`      | `.search`（只有一个状态，不读取 `-selected`） | 25 × 25pt | 75 × 75  |

规则：

- 图标按原色渲染（`.renderingMode(.original)`），**不会**被主题色染色。
- `<name>.png` 是未选中态，`<name>-selected.png` 是选中态。只提供其中一个时，两种状态共用这一张。
- 未选中态由 App 统一弱化处理：饱和度 0.22、不透明度 0.58、缩放 0.84。作者不需要自己做灰色版本。
- 图标内容建议留 2–3pt 的安全边距，保持正方形画布，透明底。
- **建议成套提供。** 只提供部分 Tab 时，没提供的 Tab 显示 SF Symbol，两种风格会混在一起。导入器遇到这种情况会给出警告，但不拒绝导入。

**书架媒体光标**：`tab/media-cursor.png`，画布 48 × 22pt（144 × 66 像素），透明 PNG，显示在书架顶部「小说 / 漫画 / 有声书」切换中选中项的下方，按原色渲染、等比放入。提供后该切换改为插画样式（下划线换成光标，顶栏底部多留 12pt）；不提供时保持下划线。只有一种状态，可配 `-dark` 版。

### 5.3 播放组件图标

所有图标都按原色渲染、scaledToFit，透明底。

| 文件名             | 听书页     | 有声书页   | 渲染尺寸                             | 建议像素                      |
| ------------------ | ---------- | ---------- | ------------------------------------ | ----------------------------- |
| `playback`         | 播放键底座 | 播放键底座 | 78pt（听书）/ 76pt（有声书），正方形 | 256 × 256                     |
| `thumb`            | 进度滑块   | 进度滑块   | 宽 ≤ `thumbWidth`，高 ≤ 30pt         | 宽 256，宽高比建议 1:1 到 2:1 |
| `timer`            | 定时       | 定时关闭   | 30pt（听书）/ 28pt（有声书）         | 256 × 256                     |
| `speed`            | 语速       | 语速       | 同上                                 | 256 × 256                     |
| `catalog`          | 目录       | 目录       | 同上                                 | 256 × 256                     |
| `read-aloud/voice` | 音色       | —          | 30pt                                 | 256 × 256                     |
| `audiobook/skip`   | —          | 跳过       | 28pt                                 | 256 × 256                     |
| `audiobook/reload` | —          | 重新拉取   | 28pt                                 | 256 × 256                     |

规则：

- **查找顺序**：`player/<页面>/<名称>` → `player/<名称>` → SF Symbol。例如有声书页的定时图标，会依次查找 `player/audiobook/timer.png`、`player/timer.png`，都没有才用系统的 `timer` 符号。
- **`playback` 只是底座。** 播放、暂停、加载符号由 App 绘制在底座上方，位置和颜色由 `player.playbackGlyph` 控制。底座图里不要画三角形或暂停符号。
- **复用位置**：播放器图标不只出现在两个播放页，下表是全部位置。「查找页面」决定该位置先查 `player/read-aloud/` 还是 `player/audiobook/` 的覆盖图，查不到再用 `player/` 下的共用图。

  | 图标                        | 出现位置                                                                                      | 查找页面   |
  | --------------------------- | --------------------------------------------------------------------------------------------- | ---------- |
  | `playback`                  | 听书页播放键                                                                                  | read-aloud |
  | `playback`                  | 有声书播放页播放键；有声书首页「正在收听」卡片播放键（缩小显示）；短剧播放页中央播放 / 暂停键 | audiobook  |
  | `thumb`                     | 听书页进度条                                                                                  | read-aloud |
  | `thumb`                     | 有声书播放页进度条；「正在收听」卡片进度条；短剧播放页进度条                                  | audiobook  |
  | `timer`、`speed`、`catalog` | 听书页快捷功能；有声书播放页功能栏                                                            | 各自页面   |
  | `voice`                     | 听书页                                                                                        | read-aloud |
  | `skip`、`reload`            | 有声书播放页功能栏                                                                            | audiobook  |

  迷你播放器胶囊不使用这些图标。

- **插画布局开关**：只要提供了任意一个 `player/` 图标，两个播放页和「正在收听」卡片就切换到插画布局，也就是现有插画主题使用的布局（功能图标 30pt，行高 88pt）。完全不提供时使用标准布局。
- 有声书页的「跳过」在用户没有自动跳过权限时会显示皇冠锁图标，这时不使用 `skip.png`，与现有行为一致。
- 画布建议是正方形，图形居中。`thumb` 例外，允许横向比例。

### 5.3.1 首页卡片装饰与迷你播放器胶囊

| 路径                  | 显示位置                                                       | 建议像素               | 说明                                                                                                                                            |
| --------------------- | -------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `card/accent`         | 书架「阅读中」「正在收听」等状态卡片上方居中，下沿压住卡片上边 | 288 × 144（96 × 48pt） | 透明 PNG；仅在卡片样式为「主题」时显示，等比放入                                                                                                |
| `player/mini-capsule` | 播放时悬浮的迷你播放器胶囊底图                                 | 702 × 156（约 4.5:1）  | PNG / JPG；铺满胶囊后裁切；左侧是封面、中间是书名、右侧是播放和关闭按钮，图案宜做成淡纹理，不要压住按钮；暗色无 `-dark` 版时以 24% 不透明度显示 |

不提供时：卡片保持原样；胶囊保持系统毛玻璃。

### 5.3.2 段评数字气泡

`comment/bubble.svg` 复用「自定义气泡」的 SVG 格式、校验器与渲染器：`{n}` 为评论数，`{c}` 为气泡颜色，`{t}` 为数字颜色，均跟随正文；黑色描边与填充会自动改为正文颜色；不支持脚本、外部图片与链接，单个文件不超过 512 KB。

- 读者的评论气泡选「跟随主题」时显示主题自带气泡；选了其他预设或自己的自定义气泡时，以读者的选择为准。
- 校验失败只提醒并忽略，不阻止导入；没有 `{n}` 时提醒评论数会自动居中。
- 不提供时「跟随主题」显示「圆润对话」。

### 5.4 神评与本章说角标

| 路径              | 显示位置                   | 标签式建议像素     | 印章式建议像素 |
| ----------------- | -------------------------- | ------------------ | -------------- |
| `comment/god`     | 正文神评卡片左侧           | 288 × 240（1.2:1） | 240 × 240      |
| `comment/chapter` | 章末评论入口、阅读设置预览 | 504 × 216（7:3）   | 96 × 96        |

- PNG，透明背景；支持 `-dark` 版本，按阅读页自身的明暗选择（不是 App 的显示模式）。
- 图片等比缩放放入对应区域，不会被拉伸。
- 比例与 `comment.style` 明显不符时给出提醒（例如标签式却是方形图）。

### 5.5 预览图

| 路径          | 建议像素                     | 用途                                                         |
| ------------- | ---------------------------- | ------------------------------------------------------------ |
| `preview.png` | 750 × 1334（竖向 9:16 左右） | 主题列表和导入确认页。缺失时 App 用 palette 自动生成色块预览 |

---

## 6. 安全限制

导入器必须拒绝以下情况，并给出友好的提示文案：

| 检查项       | 限制                                                                                                                                                 | 提示文案                     |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| 压缩包大小   | ≤ 30 MB                                                                                                                                              | 主题包过大，请压缩图片后重试 |
| 解压后总大小 | ≤ 80 MB                                                                                                                                              | 同上                         |
| 文件数量     | ≤ 80                                                                                                                                                 | 主题包内文件过多             |
| 单张图片像素 | 宽、高都 ≤ 4096，总像素 ≤ 1600 万                                                                                                                    | 「xxx.png」尺寸过大          |
| 路径         | 禁止绝对路径、反斜杠、空路径段、`.` / `..`、符号链接、加密条目、ZIP64；`__MACOSX/` 与以 `.` 开头的文件或目录（如 `.DS_Store`）直接跳过，不计入文件数 | 路径不安全，请重新打包       |
| 文件类型     | 只认识 `manifest.json` 和第 2.3 节列出的图片格式。其他文件直接忽略，并在导入报告中列出                                                               | —（仅警告）                  |
| 图片内容     | 按文件头校验真实格式，扩展名与内容不符时视为无效图片                                                                                                 | 「xxx.png」不是有效图片      |
| manifest     | 必须是合法 JSON，大小 ≤ 64 KB                                                                                                                        | 主题描述文件格式错误         |

错误分成两级：

- **致命错误**（拒绝导入）：manifest 缺失或非法、必填字段缺失、`formatVersion` 或 `minAppVersion` 不满足、触发上表任一限制、四个种子色中任一无效。
- **警告**（允许导入，在预览页列出）：可选颜色无效（按省略处理）、可选图片无效（按缺失处理）、Tab 图标不成套、存在未知文件、同一槽位有多种格式、颜色被自动校正。

导入器在 zip 解压和图片解码时使用流式读取和 `CGImageSourceCreateThumbnailAtIndex` 降采样，不把整张大图解码进内存，也不在主线程执行。

---

## 7. 导入后的存储与运行时

```text
Application Support/Themes/
└── <id>/
    ├── manifest.json          原始 manifest
    ├── palette.json           推导并校正后的完整色板，运行时直接读取
    └── images/                降采样后的图片，文件名为槽位路径把 / 换成 .，例如 tab.bookshelf-selected.png

不保留原始 `.omnitheme`，避免备份与 iCloud 同步体积翻倍。
```

- 同一个 `id` 重复导入时：`version` 更高就直接覆盖，相同或更低时询问「替换现有主题？」。
- 当前启用的自定义主题 id 存在 UserDefaults 中，使用新的 key。现有的 `AppThemeFamily` rawValue 保持不变，只新增 `custom` case。
- 运行时图片通过统一的 `ThemeImageResolver` 按槽位获取，解码结果做内存缓存。切换主题时清空缓存。
- 删除主题会移除整个 `<id>/` 目录。如果删除的是当前主题，回退到「经典书阁」。
- Pro 过期后：已导入的主题文件保留，外观回退到「经典书阁」，与现有高级主题的降级行为一致。恢复 Pro 后可以重新启用。

---

## 8. 完整示例

```json
{
  "formatVersion": 1,
  "id": "com.example.warm-sun",
  "name": "暖日拾光·自制版",
  "author": "某某",
  "version": "1.0.0",
  "description": "暖红奶黄，适合夜读的柔和配色",
  "minAppVersion": "1.4.0",
  "palette": {
    "light": {
      "backgroundPrimary": "#FFFCF4",
      "surfacePrimary": "#FFFEFA",
      "brandAccent": "#BF3F36",
      "textPrimary": "#352A25",
      "secondaryAccent": "#936800"
    },
    "dark": {
      "backgroundPrimary": "#191614",
      "surfacePrimary": "#27211B",
      "brandAccent": "#FF8D78",
      "textPrimary": "#FFF8E9",
      "onBrandAccent": "#351B16"
    }
  },
  "player": {
    "playbackGlyph": { "color": "#FFFFFF", "offsetY": 1 },
    "thumbWidth": 36
  },
  "background": {
    "app": { "overlayOpacity": 0.25 },
    "darkFallbackOpacity": 0.16
  }
}
```

最小可用主题只需要一个 `manifest.json`，内容是 `formatVersion`、`id`、`name`、`version` 加 `palette.light` 的 4 个种子色，不带任何图片。

---

## 9. 实现清单（供开发参考）

1. `AppThemeFamily` 新增 `case custom`，按编译器提示给所有 `switch` 补分支。其中 `readAloudArtworkPrefix`、`playbackCapsuleAssetName` 等返回 nil，插画布局是否启用改由主题包内容决定。
2. 新增 `ThemeImageResolver`，替换 `AppTabIcon`、`NovelReadAloudThemeIcon`、`NovelReadAloudArtworkStore`、`AudiobookThemeArtwork`、`AudiobookPlayerBackground`、`NovelReadAloudThemedBackdrop`、`AppThemeAtmosphereBackdrop` 中直接使用的 `Image(name)` / `UIImage(named:)`。
3. `ThemePackageManifest`（Codable）、`ThemePaletteResolver`（推导加对比度校正）、`ThemePackageImporter`（解压、校验、降采样、落盘），都放在 `Modules/Collection/Services/Theme/`。
4. 单元测试：manifest 解析（合法、缺字段、非法值、未知字段）、颜色推导、对比度校正、路径穿越与超限拦截、槽位查找顺序。
5. `Info.plist`：新增 `UTExportedTypeDeclarations` 和 `CFBundleDocumentTypes` 条目。
6. 藏书阁 → 主题页：导入入口（Pro 锁）、导入预览（含警告列表）、已导入主题的列表和删除。
7. 「导出内置主题为模板」：把三套插画主题的颜色和素材按本规范打包，作为作者的起点。

---

## 10. 已决问题

- **同步**：`Application Support/Themes/` 整体加入备份根目录，数据备份与 iCloud 同步都包含；当前选中的自定义主题 id 加入偏好白名单。恢复后与其他外观设置一样，重新打开 App 生效。
- **重名**：`name` 去除首尾空白后，与内置主题名称或其他 `id` 的已导入主题名称相同即为致命错误，提示「已存在名为「xxx」的主题」。
- **校验**：导入器在写入任何文件之前完成全部检查，并把问题按「致命 / 提醒」分组，逐条标注文件路径或 JSON 字段路径（例如 `palette.dark.brandAccent`、`tab/comic.png`）。存在致命问题时不导入，作者可以据此修改后重新打包。
