brainstate

zanbinxu/shuting · source

Snapshot 1 · 2026-11-05T08:35:22Z · TypeScript · MIT

Content fingerprinta5ec81048dc165f18bc75068c153386c0ed89a43d18d548dd4772b8851237539

GitHub description at capture

本地电子书阅读器,以「听读」为核心。EPUB / PDF / TXT / MOBI / AZW3,任意倍速不变调,可导出 MP3 离线听。Local e-book reader with listen-and-read: EPUB, PDF, TXT, MOBI, AZW3.

README at capture

<div align="center">

<img src="docs/logo.svg" width="112" height="112" alt="听书 ShuTing" />

# 听书 ShuTing

**本地电子书阅读器 · 以「听读」为核心**

把微信读书那种「听书 + 重排」的体验搬到本地 —— 书在你自己硬盘上,
阅读位置、收听位置、标注、缓存、导出的 MP3 也都在本地。

一本书,可以读,也可以听。

[![Tests][badge-tests]][ci]
[![Stars][badge-stars]][stargazers]
[![Forks][badge-forks]][forks]
[![Watchers][badge-watchers]][watchers]
[![Issues][badge-issues]][issues]
[![Release][badge-release]][releases]
[![Downloads][badge-downloads]][releases]
[![Last commit][badge-commit]][commits]
[![License][badge-license]](#许可证)
[![Platform][badge-platform]](#快速开始)
[![Node][badge-node]](#从源码运行)
[![PRs Welcome][badge-prs]](CONTRIBUTING.md)

[简体中文](README.md) · [English](README.en.md)

</div>

---

## 目录

- [这是什么](#这是什么)
- [界面](#界面)
- [功能](#功能)
- [支持的格式](#支持的格式)
- [快速开始](#快速开始)
- [技术亮点](#技术亮点)
- [实测数据](#实测数据)
- [架构](#架构)
- [开发](#开发)
- [常见问题](#常见问题)
- [路线图](#路线图)
- [贡献](#贡献)
- [许可证](#许可证)
- [Star History](#star-history)

---

## 这是什么

一个跑在**你自己电脑上**的电子书阅读器,核心是「听读」:

- **导入即读** —— 扫描你现有的电子书目录,不搬动文件,只记位置
- **重排正文** —— 把 EPUB / PDF / MOBI 都还原成可调字号、可换主题的连续文本
- **逐句朗读** —— 微软神经网络语音合成,跟读高亮 + 自动滚动
- **任意倍速** —— 1.1x / 2.6x / 3.0x… **不设上限**,且全程**不变调**
- **位置分开记** —— 读到哪、听到哪各记一份,互不干扰
- **离线可听** —— 导出整本 MP3,地铁上没网也能听

和「网页版听书」的区别:**书不上传,位置不上云,缓存不依赖服务商**。
整个程序可以拷进 U 盘,换台电脑双击就能用,不装 Node、不装运行时。

## 界面

<div align="center">

<img src="docs/screenshots/01-library.jpg" width="880" alt="书库界面" />

<sub><b>书库</b>:封面墙、格式筛选、进度与字数、最近阅读排序。左侧目录显示为 <code>~/…</code></sub>

<img src="docs/screenshots/02-reader.jpg" width="880" alt="阅读器与听读界面" />

<sub><b>阅读器与听读</b>:目录带每章字数与听读时长、跟读高亮、划线与想法、底部播放条(播放 / 发音 / 导出 / 定时 / 倍速)</sub>

<img src="docs/screenshots/03-formats.jpg" width="430" alt="格式顺序与默认格式" />
<img src="docs/screenshots/04-sort.jpg" width="430" alt="排序方式" />

<sub><b>左</b>:格式顺序与默认格式 —— 每种格式的数量、自定义顺序、默认打开格式(图中还展开了书库目录编辑)  <b>右</b>:排序 —— 最近阅读 / 最近加入 / 按书名 / 按篇幅 / 按进度</sub>

<img src="docs/screenshots/05-mini-player.jpg" width="880" alt="边逛边听" />

<sub><b>边逛边听</b>:返回书库后底部出现迷你播放条 —— 当前句、进度、暂停与停止都在,点标题回到那本书</sub>

</div>

> 截图取自真实的 4717 本书库(7.4 亿字)的浏览器模式。
> 注意左侧目录显示的是 `~/Downloads`、`~/Desktop`:**界面本身就把家目录折叠成 `~`**,
> 所以截图、录屏、演示都不会露出用户名。

## 功能

| 能力 | 状态 | 实测 |
|---|---|---|
| 扫描本地书库(多根目录、增量更新) | ✅ | 1134 个文件 / 首次 30 秒 / 无变化时 1.2 秒 / 零失败 |
| **EPUB** 解析(元数据 / 目录 / 封面 / 正文 / 插图) | ✅ | 46 本全通过,含 2518 章的巨型合集 |
| **TXT** 解析(GB18030 自动识别 / 章节切分 / 非书籍剔除) | ✅ | 506 本;自动剔掉依赖清单、设备配置这类假书 |
| **PDF** 解析(书签目录 / 坐标重排 / 页眉页脚剔除) | ✅ | 398 本中 **312 本可读**,合 3776 万字 |
| **MOBI / AZW3** 解析(PalmDOC 解压 / EXTH / KF8 双格式) | ✅ | 8 本中 **7 本可读**,合 164 万字 |
| **听读**(逐句合成、跟读高亮、自动滚动) | ✅ | 真实 MP3 产出 |
| **任意倍速**(1.1 / 2.6 / 3.0 … 无上限) | ✅ | 服务端 2.0x 上限 + 本地补偿,音高比 1.000x |
| **保音高变速**(WSOLA 相位声码器) | ✅ | 1.5–4.0x 全范围音高不变(朴素重采样为 2.000x) |
| **导出 MP3**(离线收听) | ✅ | 220 字/秒;19 万字的书约 15 分钟,纯 Node 无损拼接 |
| **章节统计**(每章字数 / 读时 / 听时 + 全书进度) | ✅ | 「各章之和 == 整本」有断言守住 |
| **跨书全文搜索** | ✅ | 倒排索引,中文按字 + 二字词,1721 段索引耗时 0.2 秒 |
| **书签与划线标注**(精确到字) | ✅ | 段内偏移,28 项断言守住「不丢字」 |
| **发音修正**(多音字词典,可自定义) | ✅ | 内置 42 条,只改朗读不改正文 |
| **位置记忆**(读 / 听分开存) | ✅ | 关闭重开回原位 |
| **定时关闭** | ✅ | 5–60 分钟,到时停播但保留进度 |
| **格式栏自定义**(拖拽排序 / 设默认格式) | ✅ | 25 项断言覆盖边界情况 |
| **音频缓存**(内容哈希 + LRU) | ✅ | 命中后快 334 倍 |
| **便携部署**(拷 U 盘换电脑) | ✅ | 105.9 MB zip,解压双击即用,无需 Node |
| DOCX / DOC | ⏳ | 已入库 133 本,正文解析待接入 |
| 扫描版 PDF(无文字层) | ⏳ | 需要 OCR,是另一条技术路线 |
| HUFF/CDIC 压缩的 MOBI | ❌ | 明确不支持(原因见常见问题) |

## 支持的格式

| 格式 | 正文 | 目录 | 图片 | 元数据 | 说明 |
|---|:--:|:--:|:--:|:--:|---|
| EPUB | ✅ | ✅ | ✅ | ✅ | 完整支持,含 OPF / NCX / 封面 |
| TXT | ✅ | ✅ | — | 文件名 | GB18030 自动识别;行首标记切章 |
| PDF | ✅ | ✅ | ⏳ | ✅ | 只抽文字;扫描版无文字层则明确报错 |
| MOBI | ✅ | ✅ | ⏳ | ✅ | PalmDOC LZ77 / 不压缩 |
| AZW3 | ✅ | ✅ | ⏳ | ✅ | KF8;双格式文件自动取新版正文 |
| AZW | ✅ | ✅ | ⏳ | ✅ | 同 MOBI |
| DOCX / DOC | ⏳ | — | — | — | 已入库,待接入 |

**「图片」列**:EPUB 已支持(按需从 ZIP 提取 + 缓存 + 点击放大);
PDF 与 MOBI 的插图待做(PDF 需要浏览器端渲染或解析图片对象,
MOBI 需要按 `recindex` 取记录)。

## 快速开始

### 方式一:便携版(推荐给最终用户)

到 [Releases][releases] 下载 `ShuTing-<版本>-portable.zip`(约 106 MB),
解压到**任意目录**(含 U 盘),双击 `启动听书.cmd`。

- **不需要安装 Node.js**,不需要联网下载运行时
- 书库、缓存、导出的 MP3 都在程序目录的 `data\` 下 —— 整个文件夹拷走,数据跟着走
- 另有 `start-browser.cmd`:只启动服务、用浏览器访问(同一份包支持两种用法)

> Windows SmartScreen 可能提示「未知发布者」—— 这是没有代码签名证书的正常现象,
> 点「更多信息 → 仍要运行」即可。介意的话可以走方式三自己从源码跑。

### 方式二:从源码运行

要求 **Node.js ≥ 22.6**(源码直接用 `.ts` 运行,依赖 Node 的原生类型擦除)。

```bash
git clone https://github.com/ZanbinXu/shuting.git
cd shuting
npm install
npm run dev            # 浏览器打开 http://127.0.0.1:5273
```

想要桌面窗口(而不是浏览器标签页):

```bash
npm run install:electron   # 下载 Electron 运行时
npm run build
npm start
```

### 方式三:自己打包便携版

```bash
npm run package        # 产物在 dist/
npm run verify:package # 校验包完整(漏文件、体积、依赖)
```

### 首次使用

启动后到侧栏「书库根目录」填上你的电子书文件夹,点「重新扫描」。
默认已经猜了 `Downloads` / `Desktop` / `Documents` 三个位置。

## 技术亮点

几个「做之前以为简单、做起来发现全是坑」的地方,也是这个项目最值得看的部分。

### PDF:从坐标里把版面还原出来

PDF 里没有「段落」这个概念,只有一堆带坐标的文字片段。而且 pdfjs 提供的
`hasEOL` **不可靠** —— 实测某页 29 个片段里只有 8 个带它,带它的还常常是
空字符串。所以只能靠 `transform` 里的坐标自己聚行、断段:

- **聚行**:按 Y 坐标聚类(同一视觉行的 Y 有抖动)
- **断段**:基准行距取「正间距里的**最小值**」而不是中位数 ——
  段间空档会把中位数抬高,反而断不开段
- **首行缩进**:以**版心**为基准,不是拿上一行比(否则缩进段的第二行会被误判成新段)
- **剔页眉页脚**:两条判据取并集。只靠「页边重复」会漏 ——
  实测某技术书的页脚是作者名+页码(抽出来是 `Dzinosky39`),
  它在版面里并不在末行;加上「同一段短文本出现在半数以上页面」才抓到,
  一本 869 页的书因此少掉 **3147 段噪音**

`src/core/pdf-reflow.ts` 是**纯函数**,不依赖 pdfjs,因此能用合成坐标做单测。

### MOBI:拿格式自带的校验和验自己

写 MOBI 解压最危险的地方是「看起来是对的」。PalmDOC 头里的 `textLength`
就是解压后的**精确字节数**,等于格式白送了一个校验和:

```
第一次跑:解压 817495 字节 / 声明 1000555 字节   ← 少了 18%
但章节目录、段落、中文全看起来正常,只有 194 个替换字符
```

追下去是尾部「额外数据」剥离算错,每条记录少几个字节,还每隔 4096 字节
把汉字从中间切断。修法不是继续猜那个长度编码,而是换思路:**格式保证
「除最后一条外每条记录解压后恰好 recordSize 字节」**,直接按这个不变量裁。
修完 7 本全部**逐字节吻合、零替换字符**。

另一个坑是标题识别:KF8(AZW3)用规范的 `<h1>/<h2>`,而老的 MOBI7
全书 0 个 h1/h2,只用 `<font size="6">`。但同一个标签也用于**首字下沉**
(`<font size="6">我</font> 出生于1949年…`),一见大字就标标题会让整段正文
变成章标题 —— 实测《原则》因此被切成 143 章、标题是一整段正文。
判据改成「大字号 **且** 整段够短,且不是单个汉字」后是 63 章,
标题变成「1 我的探险召唤 (1949—1967年)」这样的真目录。

### 任意倍速且不变调

微软服务端 `rate` 参数在 **+100%(2.0x)处饱和**,再高也不变快。
所以超过 2.0x 的部分交给本地**相位声码器(WSOLA)**:

| 倍速 | 音高比(WSOLA) | 对照:朴素重采样 |
|---|---|---|
| 1.5x | 1.000x | 1.500x |
| 2.6x | 1.000x | 2.000x |
| 4.0x | 1.000x | 2.000x |

同时用 `AudioBufferSourceNode` 而不是 `<audio>` 标签播放 ——
后者的 `playbackRate` 会连带改变音高。

### 朗读为什么不卡

逐句合成每次有 1.2–1.4 秒的固定开销,按句请求只有 6 字/秒,
一本 19 万字的书要合成 8.7 小时。改成**按 500 字批**合成后是 **220 字/秒**,
同一本书 15 分钟合成完。再叠一层内容哈希命名 + LRU 的本地缓存,
命中的句子从 1673 ms 降到 **5 ms**。

### 双轨架构:桌面版与网页版是同一套代码

`src/renderer/src/backend.ts` 在运行时探测:有 Electron 桥就走 IPC,
否则走 HTTP。界面代码不感知自己跑在哪。因此开发时可以用浏览器调试
(`npm run dev`),发布时用 Electron 装进窗口(`npm start`),
两条路不会走偏。

## 实测数据

| 项目 | 结果 |
|---|---|
| 测试套件 | **22 个套件 / 2843 项断言 / 全部通过**(集成测试随本机书库规模伸缩)|
| 本机书库 | **4717 本**;3365 本可读,合 **7.4 亿字** |
| PDF 解析覆盖 | **1771 本中 1069 本可读(60%)**;702 本无文字层(扫描版)|
| MOBI/AZW3 覆盖 | **525 本中 450 本解压长度逐字节吻合(86%)**;73 本 HUFF/CDIC、1 本 DRM、1 本非文本 |
| PDF 打开速度 | 461 页 2.0 秒;869 页 6.0 秒;**二次打开 5 毫秒**(磁盘缓存) |
| 扫描速度 | **4870 个文件首次 210 秒**;内容未变化时只比对、不重新解析 |
| 朗读合成 | 220 字/秒(批量);缓存命中 5 ms |
| 音频缓存 | 命中后快 334 倍 |
| 变速保音高 | 1.5–4.0x 音高比 1.000x |
| 便携包 | 105.9 MB zip;解压 247 MB;生产依赖 20.4 MB |

## 架构

```
src/shared/            纯类型 + 同构逻辑(零 Node 依赖,两侧共用)
  types.ts             全部数据结构
  text.ts              分句 / 倍速换算 / 时间格式化
  wsola.ts             保音高变速(纯函数)
  stats.ts             时长与进度估算
  format-order.ts      格式栏排序
src/core/              Node 侧核心逻辑(零 Electron 依赖)
  epub.ts              EPUB:OPF / 目录 / 封面 / 正文 / 插图
  txt.ts               TXT:编码识别 / 章节切分 / 非书籍剔除
  pdf.ts               PDF:文字层判定 / 书签目录 / 抽样估算 / 磁盘缓存
  pdf-reflow.ts        PDF 版面重排(纯函数:片段 → 行 → 段落)
  mobi.ts              MOBI/AZW3:PDB 容器 / MOBI 头 / EXTH / 标题识别
  palmdoc.ts           PalmDOC LZ77 解压(纯函数)
  bytes.ts             大端整数读取(纯函数)
  esm.ts               CommonJS 产物里加载 ESM-only 依赖
  scan.ts              目录扫描与增量同步
  store.ts             书库持久化(JSON + 原子写盘 + 合并延迟写)
  tts.ts               语音合成 + 音频缓存(内容哈希 + LRU)
  search.ts            全文搜索倒排索引
  pronunciation.ts     发音修正词典
  export.ts            导出 MP3
  server.ts            本地服务:静态文件 + JSON 接口 + 音频流
src/main/              Electron 主进程(内嵌同一个 server)
src/preload/           contextBridge
src/renderer/          React 界面
  backend.ts           运行时在 IPC 与 HTTP 之间切换
scripts/               构建、测试、基准、探针(40+ 个)
```

**格式归一**:所有格式最终都转成「章节 → 段落数组」这一种中间形态,
重排引擎、朗读引擎、搜索、导出、进度都只对接它。接入新格式时
唯一可能要动的是共享类型里的可选字段(例如 PDF 的页码标注)。

**产物按运行时隔离**(这条踩过坑):`out/` 是 CommonJS(主进程 / core),
`out/renderer/` 是 ESM(浏览器)。两边 `outDir` 必须分开 ——
曾因同名文件互相覆盖,导致 `out/shared/text.js` 被编成 ESM、主进程 `require` 直接崩溃。

## 开发

```bash
npm install --ignore-scripts   # 见下方「环境约束」
npm run build                  # 清理 + 进程内编译 + rollup 打包
npm run dev                    # 服务模式(浏览器调试)
npm start                      # 桌面窗口
npm test                       # 22 个套件 / 2843 项断言
npm run check                  # 前端加载链自检(白屏排查)
npm run check:toc              # 目录一致性校验(与原 EPUB 逐项比对)
npm run verify:package         # 便携包完整性
npm run verify:bundle          # 打包产物功能自检(需先 npm run package)
```

单个套件与探针:

```bash
node scripts/test-pdf.ts               # PDF 重排单测(42 项,合成数据)
node scripts/test-pdf-integration.ts   # PDF 集成(22 项,真实书 + 磁盘缓存)
node scripts/test-mobi.ts              # MOBI 单测(72 项,合成字节)
node scripts/test-mobi-integration.ts  # MOBI 集成(63 项,textLength 逐字节校验)
node scripts/bench-pdf.ts              # PDF 抽取性能基准
node scripts/probe-mobi.ts             # 打印 MOBI 容器结构
node scripts/probe-mobi-reading.ts     # 真实 MOBI 解压验证
```

> `npm test` 跑的是**源码 `.ts`**;`out/` 里是编译产物,服务端跑的是后者。
> 改了源码要先 `npm run build` 再重启服务 —— 否则验的是旧代码。
> 这个坑真实踩过:`PDF_RULE_VERSION` 跳到 3 就是为了清理它留下的脏数据。

更细的工程记录(每条判定规则的依据、踩过的坑、当时的实测数据)见
[`docs/DESIGN.md`](docs/DESIGN.md)。

### 环境约束(重要)

这个项目最初是在一个**受限沙箱**里开发的,几个约束直接决定了技术选型:

| 约束 | 后果 |
|---|---|
| 禁止派生子进程并使用管道通信(`spawn EPERM`) | **不能用 Vite / esbuild** —— 它们的架构正是派生 + 管道。改用 `tsc`(进程内调用编译器 API)+ `rollup` |
| 无法启动 GUI(Chromium 的 mojo 用命名管道) | Electron 窗口与 headless 截图都跑不起来,界面只能靠浏览器模式自验 |
| 访问不到 github.com(TLS 被干扰) | Electron 二进制改从 `registry.npmmirror.com` 镜像下载 |
| npm postinstall 会派生子进程 | 一律 `npm install --ignore-scripts`,Electron 单独装 |

如果你在不受限的环境里开发,这些约束都不存在 —— 上面这些绕行措施
(`scripts/tsc-run.ts`、`rollup.config.mjs`、`scripts/install-electron.ts`)
可以原样保留(它们能跑),也可以按自己喜好换回标准工具链。

## 常见问题

<details>
<summary><b>端口被占用了会怎样?会把我的用户名写进配置吗?</b></summary>

**端口**:不会失败。默认用 5274(浏览器预览 5273),如果被占用会**自动顺延**
到下一个可用端口,全部占满时交给系统分配。所以不需要手动改端口。

如果端口上**已经有一个听书实例在跑**,程序会直接**复用它**而不是再起一个 ——
这一条比端口本身更重要:两个实例各持一份内存书库,后写盘的会覆盖先写的,
表现为「扫描结果过一会儿自己变回去了」。判断依据是 `/api/info` 里的应用标识,
不是「端口通不通」(别的程序占着也通)。

浏览器模式由服务端在启动后用**实际**端口打开浏览器,所以启动脚本里没有写死 URL。

**用户名**:设置里不会。凡是**程序自己写进去**的路径,落盘时一律把家目录折叠成 `~`
(库根目录与缓存目录):

```jsonc
// data/library.json —— 设置里不含任何用户名
"libraryRoots": ["~/Downloads", "~/Desktop", "~/Documents"]
```

程序内部与界面显示时再展开成绝对路径(界面里也显示为 `~/…`,悬停可看全路径,
这样截图/录屏也不会露出用户名);换台电脑打开也能解析到**那台电脑**自己的家目录。

每本书的 `path` 则是这台机器上的绝对路径,**有意不折叠**:它是「这本书在这个位置」,
折叠了并不能让书在别人机器上打开(别人的文件不在这儿),反而让
`data/library.json` 不再是一张直接可读的表。`data/` 本身也不进版本库。
</details>

<details>
<summary><b>我删掉了一些电子书、又新增了一些,会同步吗?阅读进度会不会丢?</b></summary>

点「同步书库」即可。它**不是重建**,而是与磁盘对账:

| 情况 | 处理 |
|---|---|
| 目录里多出来的书 | 新增入库 |
| 文件内容变了(重新下载、覆盖更新) | 更新,并把阅读进度与标注**迁到新记录上** |
| 文件已经不在了 | 从列表移除,但进度与标注**留下来** |
| 什么都没变 | 跳过解析(所以第二次同步很快) |

「进度不会丢」包括三种最容易吃亏的情况:

- **文件被删掉**:记录移出列表,阅读位置留成一条「无主记录」。文件以同样的
  路径与大小回来时(重新下载、把盘插回来)自动接上。
- **同一本书换了文件**:书的 ID 含文件大小与修改时间,换了文件就会换 ID。
  同步时会把进度与标注迁到新 ID 上,你感知不到这次换壳。
- **整个目录打不开**(移动硬盘没插、网络盘断开、分区没挂载):那个目录下的记录
  **一条都不动**,只在界面上提示。否则拔一次盘就会丢掉整盘书的阅读位置 ——
  而那是不可逆的。

留着但你确实不想要的无主记录,点书库右上角的「无主记录 N」清掉即可
(会二次确认)。这些记录平时只占几十 KB,所以不做自动过期。
</details>

<details>
<summary><b>界面上的新功能全都失灵 / 提示「服务端是旧版本」?</b></summary>

你正在用**新界面配旧服务进程**。

页面上的脚本与样式是每次请求都从磁盘读的,所以**刷新页面就会拿到新界面**;
而服务端代码在进程内存里,不重启就还是旧的。这个组合很难看出来,
表现却是「新功能全部失灵,界面上毫无异常」。

程序会主动检测并提示「服务端是旧版本」,按提示重启即可:

- 浏览器预览:在运行服务的窗口按 `Ctrl+C`,再重新 `npm run dev`
- 桌面版 / 便携版:关闭程序窗口,再重新双击启动

界面顶部出现这条提示时不要忽略 —— 在它消失之前,你看到的报错
(例如「未知接口」)都只是版本不匹配的副作用,不是你操作错了。
</details>
<details>
<summary><b>加了一个大目录,为什么书只进来一部分?</b></summary>

先看两点:

1. **扫描可能还在跑。** 一本几千个文件的库要十几分钟(每本 EPUB 完整解析、
   每本 PDF 抽样、每本 MOBI 解压)。界面有进度条与当前文件名,
   结束后给出「新增 / 更新 / 未变 / 拒绝 / 失败」的汇总。
2. **有没有失败的文件。** 扫描结束后若出现「N 个文件未能入库」,
   点开就能看到是哪些、为什么(DRM、文件损坏、格式不支持等)。
   单个坏文件**不会**影响其他文件。

> 早期版本这里有个严重 bug:遇到第一个解析失败的文件,
> **整次扫描会直接中止**,且结果不落盘。表现是「加了个大目录,
> 书库只进来一小部分,界面只弹一行莫名其妙的报错」,
> 很容易被误认为「不支持中文路径」或「书太多装不下」。
> 已修复并有回归测试(`scripts/test-scan.ts`)。
</details>

<details>
<summary><b>改了代码,但界面行为没变?</b></summary>

十有八九是**服务进程还是旧的**。源码由 Node 直接当 `.ts` 加载,
改了文件不重启服务,跑的还是旧逻辑 —— 这时现象与代码完全对不上,
会往错误的方向排查很久。

开发入口现在会在启动时检查:若 `src/` 里有比本进程更新的改动,
会明确打印「你跑的可能是旧代码,请重启」。

另外注意:浏览器预览(`npm run dev`,端口 5273)与桌面版(`npm start`,端口 5274)
各有**独立的数据目录**(浏览器模式用项目下的 `data/`,桌面版用
`%APPDATA%/shuting/data/`,便携版用程序旁的 `data/`)。
它们各自在内存里持有一份书库,**同时开着并各自扫描时,后写盘的会覆盖先写的**。
建议只开一个。
</details>

<details>
<summary><b>为什么不用 Calibre 转换格式?</b></summary>

初版设计确实是「所有格式交给 Calibre 转 EPUB」,实测后发现不实用:

```
输入:乡土中国(修订本).mobi   5.4 MB
运行:ebook-convert 转为 EPUB
观察:CPU 累计从 9.3s 涨到 122s,14 分钟仍未产出文件
```

单本十几分钟,几百本要按天算。**更关键的是可移植性**:便携版主打
「拷到别的电脑就能用、不装任何东西」,一旦依赖外部程序这条卖点就没了。

所以改成自己写解析器。实测反而更快:MOBI 解压一本 2.9 MB 的书不到 1 秒。
</details>

<details>
<summary><b>扫描版 PDF 为什么读不了?</b></summary>

扫描版是把每页**拍照**贴进 PDF,整份文件里没有字体对象、没有文字绘制指令,
自然抽不出文字。这需要 OCR,是另一条独立的技术路线(还得再带一个模型)。
程序会明确告诉你「几乎没有文字层(每页约 N 字)」,而不是让你点进去看到空白。
</details>

<details>
<summary><b>HUFF/CDIC 压缩的 MOBI 为什么明确不支持?</b></summary>

它需要两套表(霍夫曼码表 + CDIC 字典),公开资料里几处细节说法不一。
照猜写出来的解码器会「跑得通但输出乱码」,**那比直接报不支持更糟** ——
你会以为能读,实际读到的是垃圾。已确认的格式事实(记录位置、四个表偏移、
条目总数)都记在 `src/core/palmdoc.ts` 的注释里,留给下次接手。
校验手段还是 `textLength` 那个逐字节比对。
</details>

<details>
<summary><b>朗读需要联网吗?能离线听吗?</b></summary>

朗读调用微软云端语音合成,**听新内容需要联网**。但:

- 听过的句子有本地缓存,断网可重播
- 要离线听整本,用阅读器里的「导出」生成 MP3(19 万字的书约 15 分钟)
</details>

<details>
<summary><b>我的书会被上传吗?</b></summary>

不会。书本身**不搬动、不上传**,程序只记路径。书库、进度、标注、缓存
全部存在本地 `data/` 目录。唯一的网络请求是调用微软的语音合成接口
(只发送当前要朗读的那一句话)。
</details>

## 路线图

- [x] **M0** 工程骨架 + 纯逻辑层 + 听读可行性验证
- [x] **M1** 书库:多根目录扫描 → 持久化 → 卡片列表
- [x] **M2** EPUB 引擎:解析 + 重排 + 目录 + 阅读
- [x] **M3** 听读引擎:合成 + 跟读高亮 + 任意倍速
- [x] **M4** 记忆与统计:阅读 / 收听位置、百分比、时间预估
- [x] **M5a** TXT 解析:编码识别 + 章节切分 + 非书籍剔除
- [x] **M5b** 正文插图:懒加载提取 + 缓存 + 点击放大
- [x] **M5c** 发音修正:分句不断词 + 多音字词典 + 界面自定义
- [x] **M5d** 导出 MP3:批量合成 + 纯 Node 无损拼接
- [x] **M5e** PDF 解析:坐标重排 + 书签目录 + 磁盘缓存
- [x] **M5f** MOBI / AZW3 解析:PalmDOC 解压 + EXTH + 标题识别
- [x] **M6** 保音高变速 / 定时关闭 / 全文搜索 / 书签划线
- [x] **M7** 便携版打包(解压即用,无需 Node)
- [ ] **M8** DOCX / DOC 解析
- [ ] **M9** PDF 与 MOBI 的正文插图
- [ ] **M10** HUFF/CDIC 压缩的 MOBI
- [ ] **M11** 扫描版 PDF 的 OCR(可选,体积代价大)
- [ ] **M12** 移动端适配与 PWA

## 贡献

欢迎提 Issue 和 PR,具体流程见 [CONTRIBUTING.md](CONTRIBUTING.md)。

特别需要帮助的地方:

- **更多格式** —— DOCX 是最容易接的(就是 ZIP + XML)
- **HUFF/CDIC 压缩的 MOBI** —— 实测占书库 MOBI/AZW3 的 13%(525 本中 73 本)
- **扫描版 PDF 的 OCR** —— 702 本没有文字层,目前只能明确报「扫描版,需要 OCR」
- **平台适配** —— 目前只在 Windows 上实测过;代码里没有平台特定逻辑,macOS / Linux 应该能跑
- **实测反馈** —— 尤其是听读手感、倍速听感、大书库下的性能

如果这个项目对你有用,**点个 Star** 是最实在的支持 ⭐

## 许可证

[MIT](LICENSE) © 2026 Zanbin Xu

使用的第三方服务与数据:

- 语音合成:[edge-tts](https://github.com/rany2/edge-tts)(微软 Edge 朗读接口,非官方)
- PDF 解析:[pdfjs-dist](https://github.com/mozilla/pdf.js)(Apache-2.0)
- 界面:[React](https://react.dev)(MIT)
- 运行时:[Electron](https://www.electronjs.org)(MIT)

## Star History

<a href="https://star-history.com/#ZanbinXu/shuting&Date">
  <img src="https://api.star-history.com/svg?repos=ZanbinXu/shuting&type=Date" width="640" alt="Star History" />
</a>

<!-- 链接引用:仓库改名后只需改这一段 -->
[ci]: https://github.com/ZanbinXu/shuting/actions/workflows/ci.yml
[stargazers]: https://github.com/ZanbinXu/shuting/stargazers
[forks]: https://github.com/ZanbinXu/shuting/network/members
[watchers]: https://github.com/ZanbinXu/shuting/watchers
[issues]: https://github.com/ZanbinXu/shuting/issues
[releases]: https://github.com/ZanbinXu/shuting/releases
[commits]: https://github.com/ZanbinXu/shuting/commits/main

[badge-tests]: https://img.shields.io/github/actions/workflow/status/ZanbinXu/shuting/ci.yml?style=flat-square&label=tests&logo=githubactions&logoColor=white
[badge-stars]: https://img.shields.io/github/stars/ZanbinXu/shuting?style=flat-square&label=Stars&logo=github&color=ffcb47
[badge-forks]: https://img.shields.io/github/forks/ZanbinXu/shuting?style=flat-square&label=Forks&logo=github
[badge-watchers]: https://img.shields.io/github/watchers/ZanbinXu/shuting?style=flat-square&label=Watchers&logo=github
[badge-issues]: https://img.shields.io/github/issues/ZanbinXu/shuting?style=flat-square&label=Issues&logo=github
[badge-release]: https://img.shields.io/github/v/release/ZanbinXu/shuting?style=flat-square&label=Release&logo=github
[badge-downloads]: https://img.shields.io/github/downloads/ZanbinXu/shuting/total?style=flat-square&label=Downloads&logo=github
[badge-commit]: https://img.shields.io/github/last-commit/ZanbinXu/shuting?style=flat-square&label=Last%20commit
[badge-license]: https://img.shields.io/github/license/ZanbinXu/shuting?style=flat-square&label=License
[badge-platform]: https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-5B7CFA?style=flat-square&logo=windows&logoColor=white
[badge-node]: https://img.shields.io/badge/Node.js-%E2%89%A5%2022.6-339933?style=flat-square&logo=nodedotjs&logoColor=white
[badge-prs]: https://img.shields.io/badge/PRs-welcome-8E4DFF?style=flat-square&logo=github&logoColor=white