diff --git a/plugins/hushreader/CHANGELOG.md b/plugins/hushreader/CHANGELOG.md
index da173e00..ccce34d0 100644
--- a/plugins/hushreader/CHANGELOG.md
+++ b/plugins/hushreader/CHANGELOG.md
@@ -1,7 +1,41 @@
# Changelog
All notable changes to this project will be documented in this file.
-## [1.2.0](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16
+
+## [1.3.1](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.1) - 2026-06-17
+
+### Added
+- **窗口大小锁定开关**:功能设置中新增"窗口大小锁定"开关,开启后窗口不允许拉伸调整大小
+
+### Fixed
+- **窗口可拖动开关生效**:修复关闭"窗口可拖动"后窗口仍可拖动的问题,关闭后拖动区域 cursor 变为 default
+- **ThemeToggle 主题未随配置加载更新**:改用 `watch` + `immediate: true` 替代直接调用 `applyTheme()`,确保配置异步加载后主题也能正确应用
+- **纯色封面时误清章节缓存**:开启"显示纯色封面"时仅清除封面和自定义封面缓存,不再误删章节缓存
+- **MOBI 导入 coverUrl 未赋值**:MOBI 解析成功后将 coverUrl 赋值给 coverImage
+- **MOBI recordOffsets 边界检查**:增加 `firstRecordOffset + 16 > data.length` 检查,防止格式损坏文件导致越界
+- **MOBI firstImageIndex 为 0xffffffff 时封面索引错误**:在 extractCoverUrl 中判断 firstImageIndex 无效值,避免错误计算封面记录索引
+- **HTML 实体解码 fromCharCode → fromCodePoint**:`xx;` 和 `HH;` 解码改用 `String.fromCodePoint`,parseInt 增加 radix 参数,支持 BMP 外字符
+- **纯色封面关闭后 MOBI 封面未恢复**:新增 `resolveMobiCovers()` 函数,关闭纯色封面时同步恢复 MOBI 书籍封面(此前仅恢复 EPUB 封面)
+
+### Removed
+- **固定行数分页模式**:移除分页模式中的"固定行数"选项,统一使用自适应模式
+
+
+## [1.3.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.0) - 2026-06-17
+
+### Added
+- **MOBI 格式支持**:新增 MOBI 电子书格式的解析和阅读功能
+- **主题模式切换**:新增"主题模式"切换
+
+### Changed
+- **封面和章节内容缓存迁移至 ztools.db 数据库**:封面和章节内容不再每次实时解析,改为存到 ztools.db 数据库,提升书架加载和打开书籍的速度
+ - 封面图(coverImage/customCoverImage)存入数据库文档 `cover_{bookId}` / `custom_cover_{bookId}`,书架加载时从数据库恢复
+ - 章节内容存入数据库文档 `chapters_{bookId}`,打开书籍时优先从数据库加载,文件修改后自动重新解析并更新缓存
+ - 删除书籍时同步清理数据库中对应的封面和章节文档
+ - 开启"显示纯色封面"时清除数据库中的封面数据
+ - dbStorage 中仅保留轻量数据:书籍列表(不含封面)、阅读进度、配置
+
+## [1.2.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.2.0) - 2026-06-16
### Fixed
- **移除大体积缓存,改为实时解析**:EPUB 封面和章节内容不再持久化到 dbStorage,改为每次需要时从文件实时解析,彻底解决存储空间溢出导致书籍丢失的问题
@@ -13,12 +47,12 @@ All notable changes to this project will be documented in this file.
### Added
- **显示纯色封面选项**:其他设置中新增"显示纯色封面"开关,开启后 EPUB 不再解析封面图片,所有书籍使用纯色背景封面,节省性能消耗
-## [1.1.1](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16
+## [1.1.1](https://github.com/me1dlinger/hushreader/releases/tag/v1.1.1) - 2026-06-16
### Fixed
- **窗口拖动/拉伸卡顿**:修复移动和拉伸阅读窗口时严重卡顿的问题——移动预览未做帧节流、预览期间重复调用 `setAlwaysOnTop`/`moveTop` 等重操作、提交时重复推送状态
-## [1.1.0](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16
+## [1.1.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.1.0) - 2026-06-16
### Fixed
- **设置编辑触发主窗口隐藏**:修复在设置界面编辑背景颜色输入框或快捷键时,每次输入/删除字符都会触发沉浸式阅读窗口 `show()`,导致主窗口被推到后面的问题
@@ -42,7 +76,7 @@ All notable changes to this project will be documented in this file.
- **百分比进度编辑跳转**:百分比进度模式下,左键点击进度组件进入编辑模式,输入 0-100 数字后按 Enter 或点击外部区域跳转到对应进度,支持 ArrowUp/Down 微调(Shift 步进 10),Escape 取消
- **书架"最近阅读"排序**:书架排序栏新增"最近阅读"选项,按最后阅读时间降序排列,未读过的书排在最后
-## [1.0.0](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16
+## [1.0.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.0.0) - 2026-06-16
### Added
- **隐阅盒阅读器插件**:隐阅盒阅读器插件初版实现
diff --git a/plugins/hushreader/README.md b/plugins/hushreader/README.md
index 786b3f8b..b7ca6d62 100644
--- a/plugins/hushreader/README.md
+++ b/plugins/hushreader/README.md
@@ -2,7 +2,7 @@
# 隐阅盒 · HushReader
-适配 [ZTools](https://github.com/ZToolsCenter/ZTools) 的阅读插件,支持 TXT / EPUB 格式
+适配 [ZTools](https://github.com/ZToolsCenter/ZTools) 的阅读插件,支持 TXT / EPUB / MOBI 格式
@@ -94,7 +94,7 @@ npm run build # 构建生产版本
文本预处理
-解析 TXT / EPUB 时自动预处理:
+解析 TXT / EPUB / MOBI 时自动预处理:
- `\r\n` / `\r` → 统一为 `\n`
- Tab / 全角空格 → 普通空格
@@ -104,6 +104,62 @@ npm run build # 构建生产版本
+
+MOBI 格式解析
+
+### 加密检测(DRM)
+
+本应用**不支持**解密 DRM 加密的书籍,但会正确识别加密类型并返回用户友好的提示:
+
+| 加密类型 | 说明 | 提示信息 |
+| -------- | ---- | -------- |
+| `0` | 未加密 | 正常解析正文 |
+| `1` | 旧版 Mobipocket(单 PID) | 提示使用旧版加密方案,正文受保护 |
+| `2` | Mobipocket/Kindle DRM(多 PID) | 提示使用现代加密方案,正文受保护 |
+
+**即使书籍加密,仍可读取**:标题、作者、封面等元数据(这些存储在未加密的记录区域)。
+
+### 压缩格式支持
+
+| 压缩类型 | 值 | 支持情况 |
+| -------- | --- | -------- |
+| `COMPRESSION_NONE` | `1` | ✅ 支持,直接读取原始内容 |
+| `COMPRESSION_PALMDOC` | `2` | ✅ 支持,实现了 PalmDOC 解压缩算法 |
+| `COMPRESSION_HUFFCDIC` | `17480` | ❌ 暂不支持,返回明确提示 |
+
+### 封面提取
+
+封面图片存储在独立的资源记录中(不受正文加密影响):
+
+1. 从 EXTH 记录 201 获取封面索引偏移
+2. 将偏移值与 MOBI 头的 `firstImageIndex` 相加得到实际记录索引
+3. 从对应 PDB 记录中提取图片数据
+4. 自动检测图片格式(JPEG / PNG / GIF),转换为 Base64 URL
+
+### 解析流程
+
+```
+PDB 文件头 (78字节) → 读取总记录数
+ ↓
+记录偏移表 → 获取每条记录的起始位置
+ ↓
+Record 0 → PalmDOC 头 + MOBI 头 + EXTH 记录
+ ↓
+元数据提取:标题(FullName)、作者(EXTH 100)、封面(EXTH 201)
+ ↓
+加密检测 → 若加密,返回元数据 + 提示信息
+ ↓
+正文记录 → 按 firstNonBookIndex 或 recordCount 读取文本记录
+ ↓
+解压缩 → PalmDOC 压缩格式解码
+ ↓
+编码检测 → UTF-8 或 Windows-1252 解码
+ ↓
+HTML/纯文本判断 → 分章解析或按 TXT 逻辑处理
+```
+
+
+
## 项目结构
```
@@ -117,13 +173,17 @@ npm run build # 构建生产版本
├── src/
│ ├── App.vue # 根组件
│ ├── main.ts # 应用入口
+│ ├── main.css # 应用样式
+│ ├── env.d.ts #环境变量类型定义
│ ├── stores/
│ │ ├── books.ts # 书籍数据 + 持久化
│ │ ├── config.ts # 配置数据 + 持久化
│ │ └── reader.ts # 阅读器状态 + 分页
│ ├── utils/
+│ │ ├── db.ts # 数据库操作工具
│ │ ├── txtParser.ts # TXT 解析 + 分页 + 预处理
-│ │ └── epubParser.ts # EPUB 解析
+│ │ ├── epubParser.ts # EPUB 解析
+│ │ └── mobiParser.ts # MOBI 解析 + 加密检测 + 封面提取
│ └── components/
│ ├── Bookshelf/ # 书架组件
│ │ ├── index.vue
diff --git a/plugins/hushreader/public/hushreader.html b/plugins/hushreader/public/hushreader.html
index bf60a5f5..8522bf52 100644
--- a/plugins/hushreader/public/hushreader.html
+++ b/plugins/hushreader/public/hushreader.html
@@ -215,7 +215,6 @@
grid-template-rows: minmax(0, 1fr);
min-width: 0;
overflow: hidden;
- cursor: grab;
touch-action: none;
}
@@ -223,6 +222,18 @@
cursor: grabbing;
}
+ body.no-move .hushreader-content {
+ cursor: default;
+ }
+
+ body.no-move.is-moving .hushreader-content {
+ cursor: default;
+ }
+
+ body.no-resize .hushreader-resize-zone {
+ cursor: default;
+ }
+
.hushreader-lines {
display: grid;
align-content: center;
@@ -274,22 +285,21 @@
}
.show-meta .hushreader-reader {
- grid-template-rows: 18px minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr);
}
.show-meta .hushreader-content {
- grid-row: 2;
+ grid-row: 1;
}
.show-meta .hushreader-meta {
- position: relative;
+ position: absolute;
+ bottom: 1px;
+ right: 32px;
top: auto;
- right: auto;
- grid-row: 1;
- grid-column: 1;
- align-self: start;
- justify-self: end;
- margin: 2px 32px 0 10px;
+ left: auto;
+ z-index: 4;
+ margin: 0;
opacity: 0.66;
pointer-events: auto;
cursor: default;
@@ -359,20 +369,6 @@
background: rgba(255, 255, 255, 0.15);
}
- @media (max-height: 42px) {
- .show-meta .hushreader-reader {
- grid-template-rows: minmax(0, 1fr);
- }
-
- .show-meta .hushreader-content {
- grid-row: 1;
- }
-
- .show-meta .hushreader-meta {
- display: none;
- }
- }
-
.hushreader-reader {
border: 0;
background: var(--hushreader-bg-color);
@@ -385,7 +381,7 @@
}
.hushreader-meta {
- background: rgba(0, 0, 0, 0.3);
+ background: transparent;
color: var(--hushreader-text-color);
}
@@ -489,7 +485,7 @@
function normalizeResizeSize(width, height) {
return {
width: clamp(snap(width, 10), getResizeLimit("minWidth", 280), getResizeLimit("maxWidth", 1180)),
- height: clamp(snap(height, 2), getResizeLimit("minHeight", 22), getResizeLimit("maxHeight", 160))
+ height: clamp(snap(height, 2), getResizeLimit("minHeight", 22), getResizeLimit("maxHeight", 500))
}
}
@@ -728,6 +724,8 @@
settings.showHushreaderMeta ? "show-meta" : "",
isAutoHidden && visible && hideMode === 'hide-all' ? "is-auto-hidden" : "",
isAutoHidden && visible && hideMode === 'show-progress' ? "is-auto-hidden-show-progress" : "",
+ !settings.windowMovable ? "no-move" : "",
+ settings.windowSizeLocked ? "no-resize" : "",
visible ? "" : "is-hidden"
]
.filter(Boolean)
@@ -886,6 +884,10 @@
document.addEventListener("pointerdown", (event) => {
const handle = event.target.closest("[data-resize]")
if (handle) {
+ const isSizeLocked = Boolean(latestSettings.windowSizeLocked)
+
+ if (isSizeLocked) return
+
event.preventDefault()
event.stopPropagation()
@@ -908,6 +910,7 @@
const moveHandle = event.target.closest("[data-move]")
if (!moveHandle || resizeState || event.button !== 0) return
+ if (!latestSettings.windowMovable) return
event.preventDefault()
event.stopPropagation()
diff --git a/plugins/hushreader/public/plugin.json b/plugins/hushreader/public/plugin.json
index e7178989..f3822f18 100644
--- a/plugins/hushreader/public/plugin.json
+++ b/plugins/hushreader/public/plugin.json
@@ -2,9 +2,9 @@
"$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json",
"name": "hushreader",
"title": "隐阅盒",
- "description": "沉浸式阅读,注意身后",
+ "description": "隐阅盒,ZTools自己的摸鱼阅读,支持TXT/EPUB/MOBI格式,沉浸式阅读",
"author": "meidlinger",
- "version": "1.2.0",
+ "version": "1.3.1",
"main": "index.html",
"preload": "preload/services.js",
"logo": "logo.png",
diff --git a/plugins/hushreader/src/App.vue b/plugins/hushreader/src/App.vue
index b1eb6757..43c6bc93 100644
--- a/plugins/hushreader/src/App.vue
+++ b/plugins/hushreader/src/App.vue
@@ -1,11 +1,14 @@
+
+
+
+
+
+
diff --git a/plugins/hushreader/src/components/Bookshelf/index.vue b/plugins/hushreader/src/components/Bookshelf/index.vue
index 2b66ba57..f9619eb4 100644
--- a/plugins/hushreader/src/components/Bookshelf/index.vue
+++ b/plugins/hushreader/src/components/Bookshelf/index.vue
@@ -5,11 +5,14 @@ import { useConfigStore } from '../../stores/config'
import { useReaderStore } from '../../stores/reader'
import { parseTxt } from '../../utils/txtParser'
import { parseEpub } from '../../utils/epubParser'
+import { parseMobi } from '../../utils/mobiParser'
+import { saveCover, loadCover, removeCover, saveCustomCover, loadCustomCover, removeCustomCover, removeBookData } from '../../utils/db'
import SettingsModal from '../Settings/index.vue'
import ContextMenu from './ContextMenu.vue'
import BookCard from './BookCard.vue'
import Toast from './Toast.vue'
import Modal from './Modal.vue'
+import ThemeToggle from './ThemeToggle.vue'
const props = defineProps<{ enterAction?: any }>()
@@ -87,6 +90,15 @@ async function openChapterList(bookId: string) {
const text = window.services?.readFile(book.filePath) ?? ''
const chapters = parseTxt(text, configStore.config.other.chapterRegex || undefined)
chapterListItems.value = chapters.map(c => ({ index: c.index, title: c.title }))
+ } else if (book.format === 'mobi') {
+ const content = window.services?.readFileBinary?.(book.filePath)
+ if (content) {
+ const blob = new Blob([content], { type: 'application/x-mobipocket-ebook' })
+ const file = new File([blob], book.filePath.split(/[\\/]/).pop() ?? 'book.mobi')
+ const result = await parseMobi(file)
+ if (result.error) { toast(`加载章节失败:${result.error}`, 'error'); showChapterList.value = false; return }
+ chapterListItems.value = result.chapters.map(c => ({ index: c.index, title: c.title }))
+ }
} else {
const content = window.services?.readFileBinary?.(book.filePath)
if (content) {
@@ -214,7 +226,9 @@ function openCoverPicker(bookId: string) {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
- bookStore.updateBook(bookId, { customCoverImage: reader.result as string })
+ const data = reader.result as string
+ bookStore.updateBook(bookId, { customCoverImage: data })
+ saveCustomCover(bookId, data).catch(() => {})
toast('封面已更新', 'success')
}
reader.readAsDataURL(file)
@@ -226,12 +240,14 @@ async function repairCover(bookId: string) {
const book = bookStore.books.find(b => b.id === bookId)
if (!book || book.format !== 'epub' || configStore.config.other.plainTextCover) {
bookStore.updateBook(bookId, { coverImage: undefined })
+ removeCover(bookId).catch(() => {})
return
}
try {
const content = window.services?.readFileBinary?.(book.filePath)
if (!content) {
bookStore.updateBook(bookId, { coverImage: undefined })
+ removeCover(bookId).catch(() => {})
return
}
const blob = new Blob([content], { type: 'application/epub+zip' })
@@ -239,11 +255,14 @@ async function repairCover(bookId: string) {
const result = await parseEpub(file)
if (result.coverUrl) {
bookStore.updateBook(bookId, { coverImage: result.coverUrl })
+ saveCover(bookId, result.coverUrl).catch(() => {})
} else {
bookStore.updateBook(bookId, { coverImage: undefined })
+ removeCover(bookId).catch(() => {})
}
} catch {
bookStore.updateBook(bookId, { coverImage: undefined })
+ removeCover(bookId).catch(() => {})
}
}
@@ -270,14 +289,15 @@ async function importBook(filePath: string) {
const name = filePath.split(/[\\/]/).pop() ?? ''
const isEpub = /\.epub$/i.test(name)
const isTxt = /\.txt$/i.test(name)
- if (!isEpub && !isTxt) {
- toast('仅支持 EPUB 和 TXT 格式', 'error')
+ const isMobi = /\.mobi$/i.test(name)
+ if (!isEpub && !isTxt && !isMobi) {
+ toast('仅支持 EPUB、TXT 和 MOBI 格式', 'error')
return
}
isLoading.value = true
try {
- let title = name.replace(/\.(epub|txt)$/i, '')
+ let title = name.replace(/\.(epub|txt|mobi)$/i, '')
let author = ''
let coverColor = randomCoverColor()
let coverImage: string | undefined
@@ -296,11 +316,28 @@ async function importBook(filePath: string) {
} catch {}
}
+ if (isMobi) {
+ try {
+ const content = window.services?.readFileBinary?.(filePath)
+ if (content) {
+ const blob = new Blob([content], { type: 'application/x-mobipocket-ebook' })
+ const file = new File([blob], name)
+ const result = await parseMobi(file)
+ if (result.error) { toast(`MOBI解析失败:${result.error}`, 'error'); return }
+ title = result.title || title
+ author = result.author || ''
+ if (result.coverUrl && !configStore.config.other.plainTextCover) coverImage = result.coverUrl
+ }
+ } catch (e: any) {
+ toast(`MOBI导入失败:${e.message}`, 'error'); return
+ }
+ }
+
const fileModifiedAt = window.services?.getFileModifiedTime?.(filePath)
const book = bookStore.addBook({
title, author,
- format: isEpub ? 'epub' : 'txt',
+ format: isEpub ? 'epub' : isMobi ? 'mobi' : 'txt',
filePath,
coverColor,
coverImage,
@@ -308,6 +345,7 @@ async function importBook(filePath: string) {
})
if (book) {
+ if (coverImage) saveCover(book.id, coverImage).catch(() => {})
toast(`《${title}》已加入书架`, 'success')
} else {
toast('该书籍已在书架中', 'info')
@@ -323,7 +361,7 @@ function handleAddBook() {
const picker = window.ztools?.showOpenDialog({
title: '选择书籍',
buttonLabel: '导入',
- filters: [{ name: '书籍文件', extensions: ['epub', 'txt'] }],
+ filters: [{ name: '书籍文件', extensions: ['epub', 'txt', 'mobi'] }],
properties: ['openFile']
})
@@ -380,6 +418,11 @@ async function resolveEpubCovers() {
const epubBooks = bookStore.books.filter(b => b.format === 'epub' && !b.coverImage && !b.customCoverImage)
for (const book of epubBooks) {
try {
+ const cached = await loadCover(book.id)
+ if (cached) {
+ book.coverImage = cached
+ continue
+ }
const content = window.services?.readFileBinary?.(book.filePath)
if (!content) continue
const blob = new Blob([content], { type: 'application/epub+zip' })
@@ -387,6 +430,30 @@ async function resolveEpubCovers() {
const result = await parseEpub(file)
if (result.coverUrl) {
book.coverImage = result.coverUrl
+ saveCover(book.id, result.coverUrl).catch(() => {})
+ }
+ } catch {}
+ }
+}
+
+async function resolveMobiCovers() {
+ if (configStore.config.other.plainTextCover) return
+ const mobiBooks = bookStore.books.filter(b => b.format === 'mobi' && !b.coverImage && !b.customCoverImage)
+ for (const book of mobiBooks) {
+ try {
+ const cached = await loadCover(book.id)
+ if (cached) {
+ book.coverImage = cached
+ continue
+ }
+ const content = window.services?.readFileBinary?.(book.filePath)
+ if (!content) continue
+ const blob = new Blob([content], { type: 'application/x-mobipocket-ebook' })
+ const file = new File([blob], book.filePath.split(/[\\/]/).pop() ?? 'book.mobi')
+ const result = await parseMobi(file)
+ if (result.coverUrl) {
+ book.coverImage = result.coverUrl
+ saveCover(book.id, result.coverUrl).catch(() => {})
}
} catch {}
}
@@ -394,16 +461,19 @@ async function resolveEpubCovers() {
watch(() => bookStore.books.length, () => {
resolveEpubCovers()
+ resolveMobiCovers()
}, { immediate: true })
-watch(() => configStore.config.other.plainTextCover, (plain) => {
+watch(() => configStore.config.other.plainTextCover, async (plain) => {
if (plain) {
- bookStore.books.forEach(b => {
+ for (const b of bookStore.books) {
b.coverImage = undefined
b.customCoverImage = undefined
- })
+ }
+ await Promise.allSettled(bookStore.books.flatMap(b => [removeCover(b.id), removeCustomCover(b.id)]))
} else {
resolveEpubCovers()
+ resolveMobiCovers()
}
})
@@ -485,7 +555,7 @@ const cfg = computed(() => configStore.config)
添加本地书籍
- 支持 EPUB / TXT
+ 支持 EPUB / TXT / MOBI
@@ -611,6 +681,11 @@ const cfg = computed(() => configStore.config)
+
+
+
+
+
@@ -967,4 +1042,11 @@ const cfg = computed(() => configStore.config)
color: var(--c-ink-inverse);
}
.btn-danger:hover { opacity: 0.85; }
+
+.theme-toggle-fab {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 100;
+}
diff --git a/plugins/hushreader/src/components/Settings/index.vue b/plugins/hushreader/src/components/Settings/index.vue
index 971179fb..b30728f0 100644
--- a/plugins/hushreader/src/components/Settings/index.vue
+++ b/plugins/hushreader/src/components/Settings/index.vue
@@ -235,7 +235,7 @@ function commitCapture(targetArr: string[]) {
@@ -394,25 +394,17 @@ function commitCapture(targetArr: string[]) {
阅读功能
-
-
-
-
-
-
+
@@ -438,8 +430,8 @@ function commitCapture(targetArr: string[]) {
- 关闭时 EPUB 书籍将解析封面图片,可能消耗较多资源
- 所有书籍使用纯色背景封面,不解析 EPUB 封面图片
+ 关闭时书籍将解析封面图片,可能消耗较多资源
+ 所有书籍使用纯色背景封面,不解析封面图片
解析
@@ -500,7 +492,7 @@ function commitCapture(targetArr: string[]) {
.modal-overlay {
position: fixed;
inset: 0;
- background: rgba(28, 25, 23, 0.3);
+ background: var(--c-overlay-bg);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
@@ -824,7 +816,7 @@ function commitCapture(targetArr: string[]) {
.confirm-overlay {
position: fixed;
inset: 0;
- background: rgba(28, 25, 23, 0.3);
+ background: var(--c-overlay-bg);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
diff --git a/plugins/hushreader/src/main.css b/plugins/hushreader/src/main.css
index 5ad47894..2e396dec 100644
--- a/plugins/hushreader/src/main.css
+++ b/plugins/hushreader/src/main.css
@@ -41,6 +41,52 @@
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
+
+ --c-overlay-bg: rgba(28, 25, 23, 0.3);
+ --c-cover-text: #ffffff;
+ --c-cover-text-muted: rgba(255, 255, 255, 0.6);
+ --c-progress-bg: rgba(0, 0, 0, 0.5);
+ --c-progress-bar: rgba(255, 255, 255, 0.12);
+ --c-progress-label: rgba(255, 255, 255, 0.9);
+}
+
+:root[data-theme="dark"] {
+ --c-accent: #818cf8;
+ --c-accent-hover: #6366f1;
+ --c-accent-soft: #1e1b4b;
+ --c-accent-muted: #4338ca;
+
+ --c-ink: #e7e5e4;
+ --c-ink-secondary: #a8a29e;
+ --c-ink-tertiary: #78716c;
+ --c-ink-inverse: #1c1917;
+
+ --c-surface: #1c1917;
+ --c-surface-raised: #292524;
+ --c-surface-sunken: #292524;
+ --c-surface-overlay: #292524;
+
+ --c-border: #44403c;
+ --c-border-strong: #57534e;
+
+ --c-danger: #f87171;
+ --c-danger-soft: #450a0a;
+ --c-success: #4ade80;
+ --c-success-soft: #052e16;
+ --c-warning: #fbbf24;
+
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.2);
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.2);
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.2);
+ --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.35), 0 8px 10px rgba(0, 0, 0, 0.2);
+
+ --c-overlay-bg: rgba(0, 0, 0, 0.5);
+ --c-cover-text: #e7e5e4;
+ --c-cover-text-muted: rgba(231, 229, 228, 0.6);
+ --c-progress-bg: rgba(0, 0, 0, 0.6);
+ --c-progress-bar: rgba(255, 255, 255, 0.15);
+ --c-progress-label: rgba(255, 255, 255, 0.9);
}
*, *::before, *::after {
diff --git a/plugins/hushreader/src/main.ts b/plugins/hushreader/src/main.ts
index 16d1dfc4..113ed0c8 100644
--- a/plugins/hushreader/src/main.ts
+++ b/plugins/hushreader/src/main.ts
@@ -6,3 +6,12 @@ import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
+
+const saved = localStorage.getItem('hushreader_config')
+if (saved) {
+ try {
+ const cfg = JSON.parse(saved)
+ const theme = cfg?.other?.theme
+ if (theme) document.documentElement.setAttribute('data-theme', theme)
+ } catch {}
+}
diff --git a/plugins/hushreader/src/stores/books.ts b/plugins/hushreader/src/stores/books.ts
index 96617b0e..423d9c77 100644
--- a/plugins/hushreader/src/stores/books.ts
+++ b/plugins/hushreader/src/stores/books.ts
@@ -1,11 +1,12 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
+import { removeBookData, loadAllCovers } from '../utils/db'
export interface Book {
id: string
title: string
author: string
- format: 'epub' | 'txt'
+ format: 'epub' | 'txt' | 'mobi'
filePath: string
coverColor?: string
coverImage?: string
@@ -56,7 +57,7 @@ export const useBookStore = defineStore('books', () => {
const searchQuery = ref('')
const activeCategory = ref('全部')
- function load() {
+ async function load() {
try {
const data = storageGet('hushreader_books')
if (data) {
@@ -72,6 +73,15 @@ export const useBookStore = defineStore('books', () => {
delete b.customCoverImage
})
books.value = parsed
+ const ids = parsed.map((b: any) => b.id as string).filter(Boolean)
+ if (ids.length) {
+ const covers = await loadAllCovers(ids)
+ for (const book of books.value) {
+ const c = covers[book.id]
+ if (c?.cover) book.coverImage = c.cover
+ if (c?.customCover) book.customCoverImage = c.customCover
+ }
+ }
}
}
const cur = storageGet('hushreader_current')
@@ -109,6 +119,7 @@ export const useBookStore = defineStore('books', () => {
books.value = books.value.filter(b => b.id !== id)
if (currentBookId.value === id) currentBookId.value = null
save()
+ removeBookData(id).catch(() => {})
}
function updateBook(id: string, updates: Partial) {
diff --git a/plugins/hushreader/src/stores/config.ts b/plugins/hushreader/src/stores/config.ts
index 88551e05..a5c01c0c 100644
--- a/plugins/hushreader/src/stores/config.ts
+++ b/plugins/hushreader/src/stores/config.ts
@@ -39,8 +39,6 @@ export interface AppearanceConfig {
}
export interface FunctionConfig {
- pageMode: 'adaptive' | 'fixed'
- pageLines: number
scrollWheelEnabled: boolean
scrollWheelDelay: number
nextPageKeys: string[]
@@ -51,6 +49,7 @@ export interface FunctionConfig {
autoFlipInterval: number
mouseHideEnabled: boolean
windowMovable: boolean
+ windowSizeLocked: boolean
}
export interface OtherConfig {
@@ -59,6 +58,7 @@ export interface OtherConfig {
listMode: boolean
chapterRegex: string
customFonts: string[]
+ theme: 'light' | 'dark'
}
export interface ReaderConfig {
@@ -94,8 +94,6 @@ const DEFAULT_CONFIG: ReaderConfig = {
progressMode: 'percent'
},
function: {
- pageMode: 'adaptive',
- pageLines: 5,
scrollWheelEnabled: true,
scrollWheelDelay: 300,
nextPageKeys: ['ArrowRight', 'PageDown'],
@@ -105,14 +103,16 @@ const DEFAULT_CONFIG: ReaderConfig = {
autoFlipEnabled: false,
autoFlipInterval: 5000,
mouseHideEnabled: true,
- windowMovable: true
+ windowMovable: true,
+ windowSizeLocked: false
},
other: {
configName: '配置',
plainTextCover: false,
listMode: false,
chapterRegex: '',
- customFonts: []
+ customFonts: [],
+ theme: 'light'
},
appearance: {
fontSize: 16,
diff --git a/plugins/hushreader/src/utils/db.ts b/plugins/hushreader/src/utils/db.ts
new file mode 100644
index 00000000..183ff962
--- /dev/null
+++ b/plugins/hushreader/src/utils/db.ts
@@ -0,0 +1,138 @@
+const db = () => (window as any).ztools?.db
+const dbp = () => (window as any).ztools?.db?.promises
+
+export async function saveCover(bookId: string, coverData: string) {
+ try {
+ const p = dbp()
+ if (!p) return
+ const existing = await p.get(`cover_${bookId}`).catch(() => null)
+ if (existing) {
+ await p.put({ ...existing, data: coverData })
+ } else {
+ await p.put({ _id: `cover_${bookId}`, data: coverData })
+ }
+ } catch (e) {
+ console.warn('[db] saveCover failed', e)
+ }
+}
+
+export async function loadCover(bookId: string): Promise {
+ try {
+ const p = dbp()
+ if (!p) return null
+ const doc = await p.get(`cover_${bookId}`).catch(() => null)
+ return doc?.data ?? null
+ } catch {
+ return null
+ }
+}
+
+export async function removeCover(bookId: string) {
+ try {
+ const p = dbp()
+ if (!p) return
+ const doc = await p.get(`cover_${bookId}`).catch(() => null)
+ if (doc) await p.remove(doc)
+ } catch (e) {
+ console.warn('[db] removeCover failed', e)
+ }
+}
+
+export async function saveCustomCover(bookId: string, coverData: string) {
+ try {
+ const p = dbp()
+ if (!p) return
+ const existing = await p.get(`custom_cover_${bookId}`).catch(() => null)
+ if (existing) {
+ await p.put({ ...existing, data: coverData })
+ } else {
+ await p.put({ _id: `custom_cover_${bookId}`, data: coverData })
+ }
+ } catch (e) {
+ console.warn('[db] saveCustomCover failed', e)
+ }
+}
+
+export async function loadCustomCover(bookId: string): Promise {
+ try {
+ const p = dbp()
+ if (!p) return null
+ const doc = await p.get(`custom_cover_${bookId}`).catch(() => null)
+ return doc?.data ?? null
+ } catch {
+ return null
+ }
+}
+
+export async function removeCustomCover(bookId: string) {
+ try {
+ const p = dbp()
+ if (!p) return
+ const doc = await p.get(`custom_cover_${bookId}`).catch(() => null)
+ if (doc) await p.remove(doc)
+ } catch (e) {
+ console.warn('[db] removeCustomCover failed', e)
+ }
+}
+
+export async function saveChapters(bookId: string, chapters: any[]) {
+ try {
+ const p = dbp()
+ if (!p) return
+ const existing = await p.get(`chapters_${bookId}`).catch(() => null)
+ if (existing) {
+ await p.put({ ...existing, data: chapters })
+ } else {
+ await p.put({ _id: `chapters_${bookId}`, data: chapters })
+ }
+ } catch (e) {
+ console.warn('[db] saveChapters failed', e)
+ }
+}
+
+export async function loadChapters(bookId: string): Promise {
+ try {
+ const p = dbp()
+ if (!p) return null
+ const doc = await p.get(`chapters_${bookId}`).catch(() => null)
+ return Array.isArray(doc?.data) ? doc.data : null
+ } catch {
+ return null
+ }
+}
+
+export async function removeChapters(bookId: string) {
+ try {
+ const p = dbp()
+ if (!p) return
+ const doc = await p.get(`chapters_${bookId}`).catch(() => null)
+ if (doc) await p.remove(doc)
+ } catch (e) {
+ console.warn('[db] removeChapters failed', e)
+ }
+}
+
+export async function removeBookData(bookId: string) {
+ await Promise.allSettled([
+ removeCover(bookId),
+ removeCustomCover(bookId),
+ removeChapters(bookId)
+ ])
+}
+
+export async function loadAllCovers(bookIds: string[]): Promise> {
+ const result: Record = {}
+ await Promise.allSettled(
+ bookIds.map(async (id) => {
+ const [cover, customCover] = await Promise.all([
+ loadCover(id),
+ loadCustomCover(id)
+ ])
+ const entry: { cover?: string; customCover?: string } = {}
+ if (cover) entry.cover = cover
+ if (customCover) entry.customCover = customCover
+ if (cover || customCover) result[id] = entry
+ })
+ )
+ return result
+}
diff --git a/plugins/hushreader/src/utils/mobiParser.ts b/plugins/hushreader/src/utils/mobiParser.ts
new file mode 100644
index 00000000..b2c2a660
--- /dev/null
+++ b/plugins/hushreader/src/utils/mobiParser.ts
@@ -0,0 +1,521 @@
+import type { Chapter } from '../stores/reader'
+import { preprocessText, parseTxt } from './txtParser'
+
+const PALMDB_HEADER_SIZE = 78
+const RECORD_INFO_SIZE = 8
+const MOBI_HEADER_MIN_SIZE = 16
+
+const TEXT_ENCODING_UTF8 = 65001
+const TEXT_ENCODING_CP1252 = 1252
+
+// PalmDOC/MOBI compression types
+const COMPRESSION_NONE = 1
+const COMPRESSION_PALMDOC = 2
+const COMPRESSION_HUFFCDIC = 17480
+
+// PalmDOC/MOBI encryption types (see MobileRead Wiki: "MOBI" -> "MOBI DRM")
+const ENCRYPTION_NONE = 0
+const ENCRYPTION_OLD_MOBIPOCKET = 1 // single-PID scheme, PC1 stream cipher
+const ENCRYPTION_MOBIPOCKET = 2 // multi-PID scheme, much stronger
+
+interface PalmDocHeader {
+ compression: number
+ textLength: number
+ recordCount: number
+ recordSize: number
+ encryptionType: number
+}
+
+interface MobiHeader {
+ textEncoding: number
+ firstNonBookIndex: number
+ fullNameOffset: number
+ fullNameLength: number
+ language: number
+ hasEXTH: boolean
+ exthFlags: number
+ headerLength: number
+ firstImageIndex: number
+}
+
+interface EXTHRecord {
+ type: number
+ raw: Uint8Array
+ text: string
+}
+
+function readUint8(data: Uint8Array, offset: number): number {
+ return data[offset]
+}
+
+function readUint16BE(data: Uint8Array, offset: number): number {
+ return (data[offset] << 8) | data[offset + 1]
+}
+
+function readUint32BE(data: Uint8Array, offset: number): number {
+ return ((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]) >>> 0
+}
+
+function readString(data: Uint8Array, offset: number, length: number): string {
+ let result = ''
+ for (let i = 0; i < length; i++) {
+ const ch = data[offset + i]
+ if (ch === 0) break
+ result += String.fromCharCode(ch)
+ }
+ return result
+}
+
+/**
+ * The PDB file header (78 bytes) carries the TOTAL number of records in the
+ * whole file (text + image + EXTH-adjacent + index records, etc.) in its
+ * last 2 bytes. This is different from the PalmDOC header's "record count",
+ * which only counts the TEXT records and lives inside record 0's data.
+ */
+function readTotalPdbRecordCount(data: Uint8Array): number {
+ return readUint16BE(data, 76)
+}
+
+function getPalmDBRecordOffsets(data: Uint8Array, totalRecordCount: number): number[] {
+ const offsets: number[] = []
+ const base = PALMDB_HEADER_SIZE
+ for (let i = 0; i < totalRecordCount; i++) {
+ const recordOffset = base + i * RECORD_INFO_SIZE
+ if (recordOffset + 4 > data.length) break
+ offsets.push(readUint32BE(data, recordOffset))
+ }
+ return offsets
+}
+
+/**
+ * The PalmDOC header lives at the very start of record 0's data (NOT at a
+ * fixed absolute file offset) — `recordZeroOffset` must be `recordOffsets[0]`.
+ */
+function parsePalmDocHeader(data: Uint8Array, recordZeroOffset: number): PalmDocHeader {
+ return {
+ compression: readUint16BE(data, recordZeroOffset),
+ textLength: readUint32BE(data, recordZeroOffset + 4),
+ recordCount: readUint16BE(data, recordZeroOffset + 8),
+ recordSize: readUint16BE(data, recordZeroOffset + 10),
+ encryptionType: readUint16BE(data, recordZeroOffset + 12)
+ }
+}
+
+/**
+ * `recordZeroOffset` is the start of record 0 (same base as parsePalmDocHeader).
+ * The 16-byte PalmDOC header comes first, then the MOBI header (whose 'MOBI'
+ * magic therefore starts at recordZeroOffset + 16, not at recordZeroOffset).
+ */
+function parseMobiHeader(data: Uint8Array, recordZeroOffset: number): MobiHeader | null {
+ const mobiStart = recordZeroOffset + 16
+
+ if (mobiStart + MOBI_HEADER_MIN_SIZE > data.length) return null
+
+ const magic = readString(data, mobiStart, 4)
+ if (magic !== 'MOBI') return null
+
+ const headerLength = readUint32BE(data, mobiStart + 4)
+ const textEncoding = readUint32BE(data, mobiStart + 12)
+
+ let firstNonBookIndex = 0
+ let fullNameOffset = 0
+ let fullNameLength = 0
+ let language = 0
+ let hasEXTH = false
+ let exthFlags = 0
+ let firstImageIndex = 0
+
+ if (recordZeroOffset + 84 <= data.length) {
+ firstNonBookIndex = readUint32BE(data, recordZeroOffset + 80)
+ }
+ if (recordZeroOffset + 88 <= data.length) {
+ fullNameOffset = readUint32BE(data, recordZeroOffset + 84)
+ }
+ if (recordZeroOffset + 92 <= data.length) {
+ fullNameLength = readUint32BE(data, recordZeroOffset + 88)
+ }
+ if (recordZeroOffset + 96 <= data.length) {
+ language = readUint32BE(data, recordZeroOffset + 92)
+ }
+ if (recordZeroOffset + 112 <= data.length) {
+ firstImageIndex = readUint32BE(data, recordZeroOffset + 108)
+ }
+ if (recordZeroOffset + 132 <= data.length) {
+ exthFlags = readUint32BE(data, recordZeroOffset + 128)
+ hasEXTH = (exthFlags & 0x40) !== 0
+ }
+
+ return {
+ textEncoding,
+ firstNonBookIndex,
+ fullNameOffset,
+ fullNameLength,
+ language,
+ hasEXTH,
+ exthFlags,
+ headerLength,
+ firstImageIndex
+ }
+}
+
+function parseEXTHRecords(data: Uint8Array, offset: number): EXTHRecord[] {
+ const records: EXTHRecord[] = []
+ if (offset + 12 > data.length) return records
+
+ const magic = readString(data, offset, 4)
+ if (magic !== 'EXTH') return records
+
+ const recordCount = readUint32BE(data, offset + 8)
+
+ let pos = offset + 12
+ for (let i = 0; i < recordCount && pos + 8 <= data.length; i++) {
+ const recordType = readUint32BE(data, pos)
+ const recordLength = readUint32BE(data, pos + 4)
+ if (recordLength < 8 || pos + recordLength > data.length) break
+
+ const dataLength = recordLength - 8
+ const raw = data.slice(pos + 8, pos + 8 + dataLength)
+ let text = ''
+ try {
+ text = new TextDecoder('utf-8', { fatal: false }).decode(raw)
+ } catch {
+ text = readString(data, pos + 8, dataLength)
+ }
+
+ records.push({ type: recordType, raw, text })
+ pos += recordLength
+ }
+
+ return records
+}
+
+function findExthRecord(records: EXTHRecord[], type: number): EXTHRecord | undefined {
+ return records.find(r => r.type === type)
+}
+
+// Some EXTH records (cover offset, thumb offset, version numbers, ...) hold a
+// raw big-endian integer rather than text — decoding them as UTF-8 text and
+// then `parseInt`-ing the (often unprintable) result does not work.
+function exthRawToUint(raw: Uint8Array): number {
+ let value = 0
+ for (let i = 0; i < raw.length; i++) {
+ value = (value << 8) | raw[i]
+ }
+ return value >>> 0
+}
+
+function detectImageMime(bytes: Uint8Array): string | null {
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'
+ if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
+ return null
+}
+
+/**
+ * Cover art is stored as a separate, un-encrypted resource record — EXTH 201
+ * gives an index that's added to the MOBI header's "first image index" field
+ * to find the PDB record holding the cover JPEG/PNG/GIF bytes. Since this is
+ * outside the encrypted text stream, it works for protected books too.
+ */
+function extractCoverUrl(
+ data: Uint8Array,
+ recordOffsets: number[],
+ firstImageIndex: number,
+ exthRecords: EXTHRecord[]
+): string | undefined {
+ try {
+ const coverRecord = findExthRecord(exthRecords, 201)
+ if (!coverRecord) return undefined
+
+ if (firstImageIndex === 0xffffffff) return undefined
+ const coverRecordIndex = firstImageIndex + exthRawToUint(coverRecord.raw)
+ const start = recordOffsets[coverRecordIndex]
+ const end = recordOffsets[coverRecordIndex + 1] ?? data.length
+ if (start === undefined || start >= data.length || end > data.length || start >= end) return undefined
+
+ const imageBytes = data.slice(start, end)
+ const mime = detectImageMime(imageBytes)
+ if (!mime) return undefined
+
+ const blob = new Blob([imageBytes], { type: mime })
+ return URL.createObjectURL(blob)
+ } catch {
+ return undefined
+ }
+}
+
+function decompressPalmDoc(compressed: Uint8Array): Uint8Array {
+ const output: number[] = []
+ let i = 0
+
+ while (i < compressed.length) {
+ const byte = compressed[i++]
+
+ if (byte === 0) {
+ output.push(0)
+ } else if (byte >= 1 && byte <= 8) {
+ for (let j = 0; j < byte && i < compressed.length; j++) {
+ output.push(compressed[i++])
+ }
+ } else if (byte >= 9 && byte <= 0x7f) {
+ output.push(byte)
+ } else if (byte >= 0x80 && byte <= 0xbf) {
+ const next = compressed[i++] || 0
+ const distance = (((byte << 8) | next) >> 3) & 0x7ff
+ const length = (next & 0x7) + 3
+
+ for (let j = 0; j < length; j++) {
+ const srcIdx = output.length - distance
+ if (srcIdx >= 0 && srcIdx < output.length) {
+ output.push(output[srcIdx])
+ } else {
+ output.push(0x20)
+ }
+ }
+ } else if (byte >= 0xc0) {
+ output.push(0x20)
+ output.push(byte ^ 0x80)
+ }
+ }
+
+ return new Uint8Array(output)
+}
+
+function decodeMobiText(raw: Uint8Array, encoding: number): string {
+ if (encoding === TEXT_ENCODING_UTF8) {
+ return new TextDecoder('utf-8').decode(raw)
+ }
+ return new TextDecoder('windows-1252').decode(raw)
+}
+
+function stripHtmlTags(html: string): string {
+ let text = html
+ .replace(//gi, '')
+ .replace(/