generated from dellevin/template
原生epub阅读
This commit is contained in:
@@ -98,7 +98,22 @@ class DatabaseHelper {
|
||||
await _upgradeToV13(db);
|
||||
}
|
||||
if (oldVersion < 14) {
|
||||
await _createReaderBooksTable(db);
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS reader_books (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT DEFAULT '',
|
||||
cover_path TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_extension TEXT NOT NULL DEFAULT 'epub',
|
||||
last_read_cfi TEXT DEFAULT '',
|
||||
reading_percentage REAL DEFAULT 0.0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
''');
|
||||
}
|
||||
if (oldVersion < 15) {
|
||||
// 安全添加 cover_offset 列(防止列已存在时报错)
|
||||
@@ -158,7 +173,26 @@ class DatabaseHelper {
|
||||
}
|
||||
if (oldVersion < 23) {
|
||||
// 创建书籍批注表(高亮、下划线、书签)
|
||||
await _createBookAnnotationsTable(db);
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS book_annotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
book_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
cfi TEXT NOT NULL DEFAULT '',
|
||||
chapter TEXT DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'highlight',
|
||||
color TEXT NOT NULL DEFAULT 'FFEB3B',
|
||||
reader_note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_book_annotations_book_id ON book_annotations(book_id)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_book_annotations_type ON book_annotations(book_id, type)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,26 +242,6 @@ class DatabaseHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/// 升级到V14:创建阅读器书籍表
|
||||
Future<void> _createReaderBooksTable(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS reader_books (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT DEFAULT '',
|
||||
cover_path TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_extension TEXT NOT NULL DEFAULT 'epub',
|
||||
last_read_cfi TEXT DEFAULT '',
|
||||
reading_percentage REAL DEFAULT 0.0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
/// 升级到V13:创建标签表并回填已有数据
|
||||
Future<void> _upgradeToV13(Database db) async {
|
||||
await db.execute('''
|
||||
@@ -667,7 +681,10 @@ class DatabaseHelper {
|
||||
)
|
||||
''');
|
||||
|
||||
// 阅读器书籍表
|
||||
// Note Plus 块编辑器文档表
|
||||
await _createNotePlusTable(db);
|
||||
|
||||
// EPUB 阅读器书籍表
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS reader_books (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -685,11 +702,21 @@ class DatabaseHelper {
|
||||
)
|
||||
''');
|
||||
|
||||
// Note Plus 块编辑器文档表
|
||||
await _createNotePlusTable(db);
|
||||
|
||||
// 书籍批注表
|
||||
await _createBookAnnotationsTable(db);
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS book_annotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
book_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
cfi TEXT NOT NULL DEFAULT '',
|
||||
chapter TEXT DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'highlight',
|
||||
color TEXT NOT NULL DEFAULT 'FFEB3B',
|
||||
reader_note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
/// 创建 Note Plus 文档表
|
||||
@@ -710,32 +737,6 @@ class DatabaseHelper {
|
||||
''');
|
||||
}
|
||||
|
||||
/// 创建书籍批注表(高亮、下划线、书签)
|
||||
Future<void> _createBookAnnotationsTable(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS book_annotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
book_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
cfi TEXT NOT NULL DEFAULT '',
|
||||
chapter TEXT DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'highlight',
|
||||
color TEXT NOT NULL DEFAULT 'FFEB3B',
|
||||
reader_note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
// 索引:按 book_id 查询加速
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_book_annotations_book_id ON book_annotations(book_id)',
|
||||
);
|
||||
// 索引:按 book_id + type 查询加速
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_book_annotations_type ON book_annotations(book_id, type)',
|
||||
);
|
||||
}
|
||||
|
||||
// 关闭数据库
|
||||
Future close() async {
|
||||
if (_database != null) {
|
||||
|
||||
477
lib/utils/epub/epub_parser.dart
Normal file
477
lib/utils/epub/epub_parser.dart
Normal file
@@ -0,0 +1,477 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
import 'reader_models.dart';
|
||||
|
||||
/// EPUB 解析器 - 从 ZIP 归档中解析 EPUB 结构
|
||||
class EpubParser {
|
||||
/// 从文件路径解析 EPUB
|
||||
Future<EpubBookInfo?> parseFromFile(String filePath,
|
||||
{String? fileName}) async {
|
||||
try {
|
||||
debugPrint('[EpubParser] 开始解析: $filePath');
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
debugPrint('[EpubParser] 读取 ${bytes.length} 字节');
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
debugPrint('[EpubParser] ZIP 解码成功, ${archive.files.length} 个文件');
|
||||
return _parseFromArchive(archive, fileName: fileName);
|
||||
} catch (e, stack) {
|
||||
debugPrint('[EpubParser] 解析失败: $e');
|
||||
debugPrint('[EpubParser] $stack');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
EpubBookInfo? _parseFromArchive(Archive archive, {String? fileName}) {
|
||||
try {
|
||||
// 检查加密(只拒绝真正阻止内容读取的加密,忽略字体混淆等)
|
||||
final encFile = archive.findFile('META-INF/encryption.xml');
|
||||
if (encFile != null) {
|
||||
try {
|
||||
final encContent = utf8.decode(encFile.content as List<int>);
|
||||
final encDoc = XmlDocument.parse(encContent);
|
||||
// 如果有 EncryptedData 且不是字体文件,则拒绝
|
||||
final encryptedData = encDoc.findAllElements('EncryptedData');
|
||||
for (final ed in encryptedData) {
|
||||
final cipherRef = ed.findAllElements('CipherReference').firstOrNull;
|
||||
final uri = cipherRef?.getAttribute('URI') ?? '';
|
||||
// 非字体文件被加密 → 真正的 DRM
|
||||
if (!uri.endsWith('.ttf') &&
|
||||
!uri.endsWith('.otf') &&
|
||||
!uri.endsWith('.woff') &&
|
||||
!uri.endsWith('.woff2')) {
|
||||
debugPrint('[EpubParser] 内容加密的 EPUB,不支持: $uri');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
debugPrint('[EpubParser] 仅字体混淆,继续解析');
|
||||
} catch (e) {
|
||||
debugPrint('[EpubParser] encryption.xml 解析失败,跳过: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final opfPath = _findOpfPath(archive);
|
||||
debugPrint('[EpubParser] OPF 路径: $opfPath');
|
||||
if (opfPath == null) return null;
|
||||
|
||||
final opfFile = archive.findFile(opfPath);
|
||||
debugPrint('[EpubParser] OPF 文件: ${opfFile != null ? '找到' : '未找到'}');
|
||||
if (opfFile == null) return null;
|
||||
|
||||
final opfContent = utf8.decode(opfFile.content as List<int>);
|
||||
debugPrint('[EpubParser] OPF 内容长度: ${opfContent.length}');
|
||||
return _parseOpf(opfContent, opfPath, archive, fileName);
|
||||
} catch (e, stack) {
|
||||
debugPrint('[EpubParser] _parseFromArchive 失败: $e');
|
||||
debugPrint('[EpubParser] $stack');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找 OPF 文件路径
|
||||
String? _findOpfPath(Archive archive) {
|
||||
// 策略1: 解析 container.xml
|
||||
final containerFile = archive.findFile('META-INF/container.xml');
|
||||
if (containerFile != null) {
|
||||
try {
|
||||
final content = utf8.decode(containerFile.content as List<int>);
|
||||
final doc = XmlDocument.parse(content);
|
||||
final rootfile = doc.findAllElements('rootfile').firstOrNull;
|
||||
if (rootfile != null) {
|
||||
final fullPath = rootfile.getAttribute('full-path');
|
||||
if (fullPath != null) return fullPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 策略2: 常见路径
|
||||
const commonPaths = [
|
||||
'content.opf',
|
||||
'OEBPS/content.opf',
|
||||
'OPS/content.opf',
|
||||
'EPUB/content.opf',
|
||||
];
|
||||
for (final path in commonPaths) {
|
||||
if (archive.findFile(path) != null) return path;
|
||||
}
|
||||
|
||||
// 策略3: 扫描 .opf 文件
|
||||
for (final file in archive.files) {
|
||||
if (file.name.endsWith('.opf')) return file.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 解析 OPF 文件
|
||||
EpubBookInfo? _parseOpf(
|
||||
String content, String opfPath, Archive archive, String? fileName) {
|
||||
final opfDir =
|
||||
opfPath.contains('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
|
||||
|
||||
final doc = XmlDocument.parse(content);
|
||||
final package = doc.rootElement;
|
||||
final version = package.getAttribute('version') ?? '2.0';
|
||||
|
||||
final metadata = package.findElements('metadata').firstOrNull;
|
||||
final manifest = package.findElements('manifest').firstOrNull;
|
||||
final spine = package.findElements('spine').firstOrNull;
|
||||
debugPrint('[EpubParser] metadata=${metadata != null}, manifest=${manifest != null}, spine=${spine != null}');
|
||||
if (metadata == null || manifest == null || spine == null) return null;
|
||||
|
||||
// 解析 manifest (id -> href)
|
||||
final manifestMap = <String, String>{};
|
||||
final manifestProperties = <String, String>{};
|
||||
for (final item in manifest.findElements('item')) {
|
||||
final id = item.getAttribute('id');
|
||||
final href = item.getAttribute('href');
|
||||
final properties = item.getAttribute('properties');
|
||||
if (id != null && href != null) {
|
||||
manifestMap[id] = _resolveRelativePath(opfDir, _normalizePath(href));
|
||||
if (properties != null) manifestProperties[id] = properties;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 metadata
|
||||
final titles = _findByLocalName(metadata, 'title')
|
||||
.map((e) => e.innerText.trim())
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList();
|
||||
final authors = _findByLocalName(metadata, 'creator')
|
||||
.map((e) => e.innerText.trim())
|
||||
.where((a) => a.isNotEmpty)
|
||||
.toList();
|
||||
final description =
|
||||
_findByLocalName(metadata, 'description').firstOrNull?.innerText.trim();
|
||||
|
||||
// 解析 spine
|
||||
final spineItems = <SpineItem>[];
|
||||
final spineIndexMap = <String, int>{};
|
||||
int index = 0;
|
||||
for (final itemref in spine.findElements('itemref')) {
|
||||
final idref = itemref.getAttribute('idref');
|
||||
final linearAttr = itemref.getAttribute('linear');
|
||||
final isLinear =
|
||||
linearAttr == null || linearAttr.toLowerCase() != 'no';
|
||||
if (idref != null && manifestMap.containsKey(idref)) {
|
||||
final href = manifestMap[idref]!;
|
||||
spineItems.add(SpineItem(
|
||||
index: index,
|
||||
href: href,
|
||||
idref: idref,
|
||||
linear: isLinear,
|
||||
));
|
||||
spineIndexMap[href] = index;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 TOC
|
||||
List<TocEntry> toc = [];
|
||||
|
||||
// EPUB 3 NAV 文档
|
||||
String? navId;
|
||||
for (final entry in manifestProperties.entries) {
|
||||
if (_containsWholeWord(entry.value, 'nav')) {
|
||||
navId = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (navId != null && manifestMap.containsKey(navId)) {
|
||||
final navPath = manifestMap[navId]!;
|
||||
final navFile = archive.findFile(navPath);
|
||||
if (navFile != null) {
|
||||
try {
|
||||
final navContent = utf8.decode(navFile.content as List<int>);
|
||||
final navDir = navPath.contains('/')
|
||||
? navPath.substring(0, navPath.lastIndexOf('/'))
|
||||
: '';
|
||||
toc = _parseNav(navContent, navDir, spineIndexMap);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// EPUB 2 NCX 回退
|
||||
if (toc.isEmpty) {
|
||||
final tocId = spine.getAttribute('toc');
|
||||
if (tocId != null && manifestMap.containsKey(tocId)) {
|
||||
final tocPath = manifestMap[tocId]!;
|
||||
final tocFile = archive.findFile(tocPath);
|
||||
if (tocFile != null) {
|
||||
try {
|
||||
final tocContent = utf8.decode(tocFile.content as List<int>);
|
||||
final ncxDir = tocPath.contains('/')
|
||||
? tocPath.substring(0, tocPath.lastIndexOf('/'))
|
||||
: '';
|
||||
toc = _parseNcx(tocContent, ncxDir, spineIndexMap);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终回退: 从 spine 生成平坦目录
|
||||
if (toc.isEmpty) {
|
||||
int chNum = 1;
|
||||
for (final si in spineItems) {
|
||||
if (!si.linear) continue;
|
||||
toc.add(TocEntry(
|
||||
label: '第 $chNum 章',
|
||||
href: '${si.href}#top',
|
||||
spineIndex: si.index,
|
||||
));
|
||||
chNum++;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测封面
|
||||
String? coverHref = _detectCover(metadata, manifestMap, manifestProperties, archive, opfDir);
|
||||
|
||||
final title = titles.isNotEmpty
|
||||
? titles.first
|
||||
: (fileName ?? '').split('/').last.split('.').first;
|
||||
|
||||
return EpubBookInfo(
|
||||
title: title,
|
||||
author: authors.isNotEmpty ? authors.first : '',
|
||||
authors: authors,
|
||||
description: description,
|
||||
coverHref: coverHref,
|
||||
opfRootPath: opfPath,
|
||||
epubVersion: version,
|
||||
spine: spineItems,
|
||||
toc: toc,
|
||||
);
|
||||
}
|
||||
|
||||
/// 检测封面图片路径(相对于 OPF 目录)
|
||||
String? _detectCover(
|
||||
XmlElement metadata,
|
||||
Map<String, String> manifestMap,
|
||||
Map<String, String> manifestProperties,
|
||||
Archive archive,
|
||||
String opfDir,
|
||||
) {
|
||||
// 策略1: meta name="cover"
|
||||
final coverMeta = metadata
|
||||
.findAllElements('meta')
|
||||
.where((e) => e.getAttribute('name') == 'cover')
|
||||
.firstOrNull;
|
||||
if (coverMeta != null) {
|
||||
final coverId = coverMeta.getAttribute('content');
|
||||
if (coverId != null && manifestMap.containsKey(coverId)) {
|
||||
final href = manifestMap[coverId]!;
|
||||
if (_isImageFile(href)) return href;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: manifest 属性包含 cover-image
|
||||
for (final entry in manifestProperties.entries) {
|
||||
if (_containsWholeWord(entry.value, 'cover-image')) {
|
||||
if (manifestMap.containsKey(entry.key)) {
|
||||
final href = manifestMap[entry.key]!;
|
||||
if (_isImageFile(href)) return href;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 常见文件名
|
||||
for (final key in manifestMap.keys) {
|
||||
final lower = key.toLowerCase();
|
||||
if (lower == 'cover.jpg' ||
|
||||
lower == 'cover.png' ||
|
||||
lower == 'cover.jpeg' ||
|
||||
lower == 'cover.webp') {
|
||||
return manifestMap[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略4: guide 中的 cover 引用
|
||||
final guideElement = XmlDocument.parse(
|
||||
'<root>${metadata.parent?.toXmlString() ?? ''}</root>')
|
||||
.rootElement
|
||||
.findElements('guide')
|
||||
.firstOrNull;
|
||||
if (guideElement != null) {
|
||||
for (final ref in guideElement.findElements('reference')) {
|
||||
final type = ref.getAttribute('type') ?? '';
|
||||
if (type.toLowerCase() == 'cover') {
|
||||
final href = ref.getAttribute('href');
|
||||
if (href != null) {
|
||||
final resolved = _resolveRelativePath(opfDir, href);
|
||||
if (_isImageFile(resolved)) return resolved;
|
||||
// href 可能指向一个 XHTML 文件,需要从中提取图片
|
||||
final coverFile = archive.findFile(resolved);
|
||||
if (coverFile != null) {
|
||||
try {
|
||||
final html = utf8.decode(coverFile.content as List<int>);
|
||||
final imgSrc = _extractFirstImage(html);
|
||||
if (imgSrc != null) {
|
||||
final hrefDir = resolved.contains('/')
|
||||
? resolved.substring(0, resolved.lastIndexOf('/'))
|
||||
: '';
|
||||
return _resolveRelativePath(hrefDir, imgSrc);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractFirstImage(String html) {
|
||||
final imgReg = RegExp(r'<img[^>]+src="([^">]+)"', caseSensitive: false);
|
||||
final match = imgReg.firstMatch(html);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
/// 解析 EPUB 3 NAV 文档
|
||||
List<TocEntry> _parseNav(
|
||||
String content, String navDir, Map<String, int> spineIndexMap) {
|
||||
try {
|
||||
final doc = XmlDocument.parse(content);
|
||||
final navElement = doc.findAllElements('nav').where((el) {
|
||||
final epubType = el.getAttribute('epub:type') ??
|
||||
el.getAttribute('type') ??
|
||||
'';
|
||||
return _containsWholeWord(epubType, 'toc');
|
||||
}).firstOrNull;
|
||||
if (navElement == null) return [];
|
||||
|
||||
final rootOl = navElement.childElements
|
||||
.where((el) => el.localName == 'ol')
|
||||
.firstOrNull;
|
||||
if (rootOl == null) return [];
|
||||
|
||||
return _parseNavListItems(rootOl.findElements('li'), navDir, spineIndexMap);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
List<TocEntry> _parseNavListItems(
|
||||
Iterable<XmlElement> items, String baseDir, Map<String, int> spineIndexMap) {
|
||||
final entries = <TocEntry>[];
|
||||
for (final li in items) {
|
||||
final anchor = li.childElements
|
||||
.where((el) => el.localName == 'a' || el.localName == 'span')
|
||||
.firstOrNull;
|
||||
final label =
|
||||
anchor?.innerText.trim().isNotEmpty == true ? anchor!.innerText.trim() : 'Chapter';
|
||||
final hrefValue =
|
||||
anchor?.localName == 'a' ? anchor!.getAttribute('href') : null;
|
||||
|
||||
String href = '';
|
||||
int spineIdx = -1;
|
||||
if (hrefValue != null && hrefValue.trim().isNotEmpty) {
|
||||
final resolved = _resolveRelativePath(baseDir, hrefValue);
|
||||
href = resolved;
|
||||
final pathOnly = href.split('#').first;
|
||||
spineIdx = spineIndexMap[pathOnly] ?? -1;
|
||||
}
|
||||
|
||||
final nestedOl = li.childElements
|
||||
.where((el) => el.localName == 'ol')
|
||||
.firstOrNull;
|
||||
final children = nestedOl != null
|
||||
? _parseNavListItems(nestedOl.findElements('li'), baseDir, spineIndexMap)
|
||||
: <TocEntry>[];
|
||||
|
||||
entries.add(TocEntry(
|
||||
label: label,
|
||||
href: href,
|
||||
spineIndex: spineIdx,
|
||||
children: children,
|
||||
));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// 解析 EPUB 2 NCX 文档
|
||||
List<TocEntry> _parseNcx(
|
||||
String content, String baseDir, Map<String, int> spineIndexMap) {
|
||||
try {
|
||||
final doc = XmlDocument.parse(content);
|
||||
final navMap = doc.findAllElements('navMap').firstOrNull;
|
||||
if (navMap == null) return [];
|
||||
return _parseNavPoints(navMap.findElements('navPoint'), baseDir, spineIndexMap);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
List<TocEntry> _parseNavPoints(
|
||||
Iterable<XmlElement> navPoints, String baseDir, Map<String, int> spineIndexMap) {
|
||||
final entries = <TocEntry>[];
|
||||
for (final np in navPoints) {
|
||||
final label = np
|
||||
.findElements('navLabel')
|
||||
.firstOrNull
|
||||
?.findElements('text')
|
||||
.firstOrNull
|
||||
?.innerText
|
||||
.trim() ??
|
||||
'Chapter';
|
||||
final src = np.findElements('content').firstOrNull?.getAttribute('src') ?? '';
|
||||
|
||||
final resolved = _resolveRelativePath(baseDir, src);
|
||||
final pathOnly = resolved.split('#').first;
|
||||
final spineIdx = spineIndexMap[pathOnly] ?? -1;
|
||||
|
||||
final children = _parseNavPoints(np.findElements('navPoint'), baseDir, spineIndexMap);
|
||||
|
||||
entries.add(TocEntry(
|
||||
label: label,
|
||||
href: resolved,
|
||||
spineIndex: spineIdx,
|
||||
children: children,
|
||||
));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── 辅助方法 ─────────────────────────────────────────────────
|
||||
|
||||
Iterable<XmlElement> _findByLocalName(XmlElement parent, String name) {
|
||||
return parent.descendantElements.where((e) => e.localName == name);
|
||||
}
|
||||
|
||||
bool _containsWholeWord(String? value, String word) {
|
||||
if (value == null || value.trim().isEmpty) return false;
|
||||
return RegExp('\\b${RegExp.escape(word)}\\b', caseSensitive: false)
|
||||
.hasMatch(value);
|
||||
}
|
||||
|
||||
String _resolveRelativePath(String baseDir, String relativePath) {
|
||||
if (baseDir.isEmpty) return relativePath;
|
||||
final baseUri = Uri.parse(baseDir.endsWith('/') ? baseDir : '$baseDir/');
|
||||
final resolved = baseUri.resolve(relativePath);
|
||||
String result = resolved.toString();
|
||||
if (result.startsWith('/')) result = result.substring(1);
|
||||
return Uri.decodeFull(result);
|
||||
}
|
||||
|
||||
String _normalizePath(String path) {
|
||||
path = path.trim();
|
||||
while (path.startsWith('/')) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
while (path.endsWith('/')) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
path = path.replaceAll(RegExp(r'/+'), '/');
|
||||
return path;
|
||||
}
|
||||
|
||||
bool _isImageFile(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
return lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.png') ||
|
||||
lower.endsWith('.webp');
|
||||
}
|
||||
}
|
||||
163
lib/utils/epub/epub_service.dart
Normal file
163
lib/utils/epub/epub_service.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'epub_parser.dart';
|
||||
import 'reader_dao.dart';
|
||||
import 'reader_models.dart';
|
||||
|
||||
/// EPUB 服务层 - 管理导入、解压、删除
|
||||
class EpubService {
|
||||
final ReaderDao _dao = ReaderDao();
|
||||
final EpubParser _parser = EpubParser();
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// 导入 EPUB 文件
|
||||
/// 返回 {'bookId': ..., 'title': ...} 或 null(解析失败)
|
||||
Future<Map<String, dynamic>?> importBook(String sourcePath) async {
|
||||
final bookId = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
final fileName = p.basename(sourcePath);
|
||||
|
||||
// 复制 EPUB 到永久存储(FilePicker 临时文件会被清理)
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final booksDir = Directory(p.join(appDir.path, 'epub_books'));
|
||||
if (!await booksDir.exists()) await booksDir.create(recursive: true);
|
||||
final permanentPath = p.join(booksDir.path, '$bookId.epub');
|
||||
await File(sourcePath).copy(permanentPath);
|
||||
|
||||
// 从永久副本解析
|
||||
final info = await _parser.parseFromFile(
|
||||
permanentPath,
|
||||
fileName: fileName,
|
||||
);
|
||||
if (info == null) return null;
|
||||
|
||||
// 解压到临时目录
|
||||
final extractDir = await getExtractDir(bookId);
|
||||
await _extractEpub(permanentPath, extractDir);
|
||||
|
||||
// 提取封面
|
||||
String? coverPath;
|
||||
if (info.coverHref != null) {
|
||||
coverPath = await _extractCover(info, extractDir, bookId);
|
||||
}
|
||||
|
||||
// 写入数据库(file_path 存永久路径)
|
||||
await _dao.insertReaderBook({
|
||||
'id': bookId,
|
||||
'title': info.title,
|
||||
'author': info.author,
|
||||
'cover_path': coverPath,
|
||||
'file_path': permanentPath,
|
||||
'file_name': fileName,
|
||||
'file_extension': 'epub',
|
||||
'last_read_cfi': '',
|
||||
'reading_percentage': 0.0,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
'is_deleted': 0,
|
||||
});
|
||||
|
||||
return {'bookId': bookId, 'title': info.title};
|
||||
}
|
||||
|
||||
/// 解压 EPUB 到目标目录
|
||||
Future<void> _extractEpub(String sourcePath, String targetDir) async {
|
||||
final dir = Directory(targetDir);
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
await dir.create(recursive: true);
|
||||
|
||||
final bytes = await File(sourcePath).readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
|
||||
for (final file in archive) {
|
||||
final filePath = p.join(targetDir, file.name);
|
||||
if (file.isFile) {
|
||||
final outFile = File(filePath);
|
||||
await outFile.parent.create(recursive: true);
|
||||
await outFile.writeAsBytes(file.content as List<int>);
|
||||
} else {
|
||||
await Directory(filePath).create(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取封面图片
|
||||
Future<String?> _extractCover(
|
||||
EpubBookInfo info, String extractDir, String bookId) async {
|
||||
try {
|
||||
final opfDir = info.opfRootPath.contains('/')
|
||||
? info.opfRootPath.substring(0, info.opfRootPath.lastIndexOf('/'))
|
||||
: '';
|
||||
final coverRelPath = opfDir.isEmpty
|
||||
? info.coverHref!
|
||||
: '$opfDir/${info.coverHref!}';
|
||||
final coverFile = File(p.join(extractDir, coverRelPath));
|
||||
if (!await coverFile.exists()) return null;
|
||||
|
||||
// 保存到应用文档目录
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final coverDir = p.join(appDir.path, 'images', 'books', bookId);
|
||||
await Directory(coverDir).create(recursive: true);
|
||||
final ext = p.extension(coverFile.path).toLowerCase();
|
||||
final destPath = p.join(coverDir, 'cover$ext');
|
||||
await coverFile.copy(destPath);
|
||||
return destPath;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取解压目录
|
||||
Future<String> getExtractDir(String bookId) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
return p.join(tempDir.path, 'epub', bookId);
|
||||
}
|
||||
|
||||
/// 确保已解压(如果临时目录被清理则重新解压)
|
||||
/// 返回解压目录路径,失败返回 null
|
||||
Future<String?> ensureExtracted(String bookId, String filePath) async {
|
||||
final extractDir = await getExtractDir(bookId);
|
||||
final dir = Directory(extractDir);
|
||||
|
||||
if (await dir.exists()) {
|
||||
final files = dir.listSync();
|
||||
if (files.isNotEmpty) return extractDir;
|
||||
}
|
||||
|
||||
// 重新解压
|
||||
if (!await File(filePath).exists()) return null;
|
||||
await _extractEpub(filePath, extractDir);
|
||||
return extractDir;
|
||||
}
|
||||
|
||||
/// 删除书籍
|
||||
Future<void> deleteBook(String bookId) async {
|
||||
// 清理解压目录
|
||||
try {
|
||||
final extractDir = await getExtractDir(bookId);
|
||||
final dir = Directory(extractDir);
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
|
||||
// 清理封面
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final coverDir = p.join(appDir.path, 'images', 'books', bookId);
|
||||
final dir = Directory(coverDir);
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
|
||||
// 清理永久 EPUB 文件
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final epubFile = File(p.join(appDir.path, 'epub_books', '$bookId.epub'));
|
||||
if (await epubFile.exists()) await epubFile.delete();
|
||||
} catch (_) {}
|
||||
|
||||
// 软删除数据库记录
|
||||
await _dao.deleteReaderBook(bookId);
|
||||
}
|
||||
}
|
||||
102
lib/utils/epub/epub_stream_service.dart
Normal file
102
lib/utils/epub/epub_stream_service.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class EpubStreamService {
|
||||
String? _currentBookPath;
|
||||
String? _pendingBookPath;
|
||||
Future<void>? _openBookFuture;
|
||||
|
||||
/// Cached decoded archive for the current book.
|
||||
Archive? _cachedArchive;
|
||||
|
||||
Future<void> warmUp() async {}
|
||||
|
||||
Future<void> openBook(String epubPath) {
|
||||
if (_currentBookPath == epubPath && _cachedArchive != null) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
if (_pendingBookPath == epubPath && _openBookFuture != null) {
|
||||
return _openBookFuture!;
|
||||
}
|
||||
|
||||
_pendingBookPath = epubPath;
|
||||
_openBookFuture = _doOpenBook(epubPath);
|
||||
return _openBookFuture!;
|
||||
}
|
||||
|
||||
Future<void> _doOpenBook(String epubPath) async {
|
||||
try {
|
||||
final bytes = await File(epubPath).readAsBytes();
|
||||
_cachedArchive = ZipDecoder().decodeBytes(bytes);
|
||||
_currentBookPath = epubPath;
|
||||
} catch (e) {
|
||||
_currentBookPath = null;
|
||||
_cachedArchive = null;
|
||||
rethrow;
|
||||
} finally {
|
||||
if (_pendingBookPath == epubPath) {
|
||||
_pendingBookPath = null;
|
||||
_openBookFuture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single file from the currently open EPUB archive.
|
||||
/// Returns the file bytes, or null if not found / no book loaded.
|
||||
Future<Uint8List?> readFileFromEpub({
|
||||
required String targetFilePath,
|
||||
String? epubPath,
|
||||
}) async {
|
||||
if (epubPath != null && epubPath != _currentBookPath) {
|
||||
await openBook(epubPath);
|
||||
}
|
||||
|
||||
if (_currentBookPath == null || _cachedArchive == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = _cachedArchive!.findFile(targetFilePath);
|
||||
if (file == null || file.content == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return file.content is Uint8List
|
||||
? file.content as Uint8List
|
||||
: Uint8List.fromList(file.content as List<int>);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_cachedArchive = null;
|
||||
_currentBookPath = null;
|
||||
_pendingBookPath = null;
|
||||
_openBookFuture = null;
|
||||
}
|
||||
|
||||
String getMimeType(String filePath) {
|
||||
final ext = p.extension(filePath).toLowerCase().replaceAll('.', '');
|
||||
return _mimeTypeMap[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
static const _mimeTypeMap = {
|
||||
'html': 'text/html',
|
||||
'htm': 'text/html',
|
||||
'xhtml': 'application/xhtml+xml',
|
||||
'xml': 'application/xml',
|
||||
'css': 'text/css',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'ttf': 'font/ttf',
|
||||
'otf': 'font/otf',
|
||||
'woff': 'font/woff',
|
||||
'woff2': 'font/woff2',
|
||||
'js': 'application/javascript',
|
||||
};
|
||||
}
|
||||
111
lib/utils/epub/epub_theme.dart
Normal file
111
lib/utils/epub/epub_theme.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'reader_scripts.dart';
|
||||
|
||||
class EpubTheme {
|
||||
final double zoom;
|
||||
final bool shouldOverrideTextColor;
|
||||
final ColorScheme colorScheme;
|
||||
final Color? overridePrimaryColor;
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// File name (with extension) of the custom font, or null for epub default.
|
||||
final String? fontFileName;
|
||||
|
||||
/// When true, force the custom font on top of the epub's own font rules.
|
||||
final bool overrideFontFamily;
|
||||
|
||||
EpubTheme({
|
||||
required this.zoom,
|
||||
required this.shouldOverrideTextColor,
|
||||
required this.colorScheme,
|
||||
this.overridePrimaryColor,
|
||||
required this.padding,
|
||||
this.fontFileName,
|
||||
this.overrideFontFamily = false,
|
||||
});
|
||||
|
||||
bool get isDark => colorScheme.brightness == Brightness.dark;
|
||||
|
||||
Color get surfaceColor => colorScheme.surface;
|
||||
|
||||
EpubTheme copyWith({
|
||||
double? zoom,
|
||||
bool? shouldOverrideTextColor,
|
||||
ColorScheme? colorScheme,
|
||||
Color? overridePrimaryColor,
|
||||
EdgeInsets? padding,
|
||||
Object? fontFileName = _kUnset,
|
||||
bool? overrideFontFamily,
|
||||
}) {
|
||||
return EpubTheme(
|
||||
zoom: zoom ?? this.zoom,
|
||||
shouldOverrideTextColor:
|
||||
shouldOverrideTextColor ?? this.shouldOverrideTextColor,
|
||||
colorScheme: colorScheme ?? this.colorScheme,
|
||||
overridePrimaryColor: overridePrimaryColor ?? this.overridePrimaryColor,
|
||||
padding: padding ?? this.padding,
|
||||
fontFileName: identical(fontFileName, _kUnset)
|
||||
? this.fontFileName
|
||||
: fontFileName as String?,
|
||||
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
static const Object _kUnset = Object();
|
||||
|
||||
Map<String, dynamic> toThemeMap() {
|
||||
return {
|
||||
'padding': {'top': padding.top, 'left': padding.left},
|
||||
'theme': {
|
||||
'zoom': zoom,
|
||||
'shouldOverrideTextColor': shouldOverrideTextColor,
|
||||
|
||||
'primaryColor': overridePrimaryColor != null
|
||||
? colorToMap(overridePrimaryColor!)
|
||||
: colorToMap(colorScheme.primary),
|
||||
'onPrimaryColor': colorToMap(colorScheme.onPrimary),
|
||||
'secondaryColor': colorToMap(colorScheme.secondary),
|
||||
'onSecondaryColor': colorToMap(colorScheme.onSecondary),
|
||||
'errorColor': colorToMap(colorScheme.error),
|
||||
'onErrorColor': colorToMap(colorScheme.onError),
|
||||
'surfaceColor': colorToMap(colorScheme.surface),
|
||||
'onSurfaceColor': colorToMap(colorScheme.onSurface),
|
||||
'primaryContainerColor': colorToMap(colorScheme.primaryContainer),
|
||||
'onSurfaceVariantColor': colorToMap(colorScheme.onSurfaceVariant),
|
||||
'outlineVariantColor': colorToMap(colorScheme.outlineVariant),
|
||||
'surfaceContainerColor': colorToMap(colorScheme.surfaceContainer),
|
||||
'surfaceContainerHighColor': colorToMap(
|
||||
colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
|
||||
'fontFileName': fontFileName,
|
||||
'overrideFontFamily': overrideFontFamily,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is EpubTheme &&
|
||||
other.zoom == zoom &&
|
||||
other.shouldOverrideTextColor == shouldOverrideTextColor &&
|
||||
other.colorScheme == colorScheme &&
|
||||
other.overridePrimaryColor == overridePrimaryColor &&
|
||||
other.padding == padding &&
|
||||
other.fontFileName == fontFileName &&
|
||||
other.overrideFontFamily == overrideFontFamily;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
zoom,
|
||||
shouldOverrideTextColor,
|
||||
colorScheme,
|
||||
overridePrimaryColor,
|
||||
padding,
|
||||
fontFileName,
|
||||
overrideFontFamily,
|
||||
);
|
||||
}
|
||||
258
lib/utils/epub/epub_webview_handler.dart
Normal file
258
lib/utils/epub/epub_webview_handler.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'epub_stream_service.dart';
|
||||
|
||||
/// Simple file reference with path and optional anchor.
|
||||
class Href {
|
||||
final String path;
|
||||
final String anchor;
|
||||
|
||||
const Href({required this.path, this.anchor = 'top'});
|
||||
|
||||
@override
|
||||
String toString() => '$path#$anchor';
|
||||
}
|
||||
|
||||
/// WebView request handler for streaming EPUB content.
|
||||
/// Intercepts requests to virtual domain and serves files from compressed EPUB.
|
||||
class EpubWebViewHandler {
|
||||
final EpubStreamService _streamService;
|
||||
|
||||
/// Virtual domain for EPUB content.
|
||||
/// Format: epub://localhost/book/{fileHash}/{filePath}
|
||||
static const String virtualDomain = 'localhost';
|
||||
static const String virtualScheme = 'epub';
|
||||
static const _headers = {'Cache-Control': 'public, max-age=31536000'};
|
||||
|
||||
EpubWebViewHandler({required EpubStreamService streamService})
|
||||
: _streamService = streamService;
|
||||
|
||||
/// Cached documents directory path.
|
||||
static String? _documentsPath;
|
||||
|
||||
static Future<String> getDocumentsPath() async {
|
||||
if (_documentsPath != null) return _documentsPath!;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_documentsPath = '${dir.path}/';
|
||||
return _documentsPath!;
|
||||
}
|
||||
|
||||
/// Create WebView resource request handler.
|
||||
/// This should be set as the shouldInterceptRequest callback.
|
||||
Future<WebResourceResponse?> handleRequest({
|
||||
required String epubPath,
|
||||
required String fileHash,
|
||||
required WebUri requestUrl,
|
||||
}) async {
|
||||
try {
|
||||
// Serve user-imported fonts.
|
||||
if (isFontRequest(requestUrl)) {
|
||||
final fontResult = await _readFontFile(requestUrl);
|
||||
if (fontResult == null) {
|
||||
return WebResourceResponse(
|
||||
statusCode: 404,
|
||||
reasonPhrase: 'Not Found',
|
||||
data: Uint8List.fromList('Font not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
return WebResourceResponse(
|
||||
contentType: fontResult.$2,
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
data: fontResult.$1,
|
||||
headers: _headers,
|
||||
);
|
||||
}
|
||||
|
||||
// Read file from EPUB
|
||||
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
|
||||
|
||||
if (result == null) {
|
||||
return WebResourceResponse(
|
||||
statusCode: 404,
|
||||
reasonPhrase: 'Not Found',
|
||||
data: Uint8List.fromList('File not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
|
||||
return WebResourceResponse(
|
||||
contentType: result.$2,
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
data: result.$1,
|
||||
headers: _headers,
|
||||
);
|
||||
} catch (e) {
|
||||
return WebResourceResponse(
|
||||
statusCode: 500,
|
||||
reasonPhrase: 'Internal Server Error',
|
||||
data: Uint8List.fromList('Error: $e'.codeUnits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<CustomSchemeResponse?> handleRequestWithCustomScheme({
|
||||
required String epubPath,
|
||||
required String fileHash,
|
||||
required WebUri requestUrl,
|
||||
}) async {
|
||||
try {
|
||||
// Serve user-imported fonts.
|
||||
if (isFontRequest(requestUrl)) {
|
||||
final fontResult = await _readFontFile(requestUrl);
|
||||
if (fontResult == null) {
|
||||
return CustomSchemeResponse(
|
||||
contentType: 'text/plain',
|
||||
data: Uint8List.fromList('Font not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
return CustomSchemeResponse(
|
||||
contentType: fontResult.$2,
|
||||
data: fontResult.$1,
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
|
||||
|
||||
if (result == null) {
|
||||
return CustomSchemeResponse(
|
||||
contentType: 'text/plain',
|
||||
data: Uint8List.fromList('File not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
|
||||
return CustomSchemeResponse(
|
||||
contentType: result.$2,
|
||||
data: result.$1,
|
||||
);
|
||||
} catch (e) {
|
||||
return CustomSchemeResponse(
|
||||
contentType: 'text/plain',
|
||||
data: Uint8List.fromList('Error reading file: $e'.codeUnits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a file from an EPUB.
|
||||
/// Returns (data, mimeType) or null on failure.
|
||||
Future<(Uint8List, String)?> _readFileFromEpub(
|
||||
String epubPath,
|
||||
String fileHash,
|
||||
WebUri requestUrl,
|
||||
) async {
|
||||
final prefix = "/book/$fileHash/";
|
||||
if (!requestUrl.path.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final decodedPath = Uri.decodeFull(requestUrl.path);
|
||||
final relativePath = decodedPath.substring(prefix.length);
|
||||
final fileRelativePath = relativePath.split('#')[0];
|
||||
|
||||
final data = await _streamService.readFileFromEpub(
|
||||
epubPath: epubPath,
|
||||
targetFilePath: fileRelativePath,
|
||||
);
|
||||
|
||||
if (data == null) return null;
|
||||
|
||||
final mimeType = _streamService.getMimeType(fileRelativePath);
|
||||
return (data, mimeType);
|
||||
}
|
||||
|
||||
/// Reads a font file from the app's fonts directory.
|
||||
/// URL format: epub://localhost/fonts/{fileName}
|
||||
Future<(Uint8List, String)?> _readFontFile(WebUri requestUrl) async {
|
||||
const prefix = '/fonts/';
|
||||
if (!requestUrl.path.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
final fileName = Uri.decodeComponent(
|
||||
requestUrl.path.substring(prefix.length),
|
||||
);
|
||||
if (fileName.isEmpty || fileName.contains('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final documentsPath = await getDocumentsPath();
|
||||
final filePath = '${documentsPath}fonts/$fileName';
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
final bytes = await file.readAsBytes();
|
||||
final ext = fileName.toLowerCase().split('.').last;
|
||||
final mimeType = _fontMimeTypes[ext] ?? 'application/octet-stream';
|
||||
return (bytes, mimeType);
|
||||
}
|
||||
|
||||
static const _fontMimeTypes = {
|
||||
'ttf': 'font/ttf',
|
||||
'otf': 'font/otf',
|
||||
'woff': 'font/woff',
|
||||
'woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
/// Generate base URL for a chapter.
|
||||
/// This URL should be used as the baseUrl parameter when loading HTML.
|
||||
static String getBaseUrl() {
|
||||
return '$virtualScheme://$virtualDomain/book/index.html';
|
||||
}
|
||||
|
||||
/// Generate full URL for a specific file.
|
||||
static String getFileUrl(String fileHash, Href href) {
|
||||
final url =
|
||||
'$virtualScheme://$virtualDomain/book/$fileHash/${href.path}${'#${href.anchor}'}';
|
||||
return Uri.encodeFull(url);
|
||||
}
|
||||
|
||||
/// Generate URL for a user-imported font file.
|
||||
/// Format: epub://localhost/fonts/{fileName}
|
||||
static String getFontUrl(String fileName) {
|
||||
return '$virtualScheme://$virtualDomain/fonts/$fileName';
|
||||
}
|
||||
|
||||
/// Check if a request is for an EPUB file.
|
||||
static bool isEpubRequest(WebUri requestUrl) {
|
||||
return requestUrl.scheme == virtualScheme &&
|
||||
requestUrl.host == virtualDomain &&
|
||||
requestUrl.path.startsWith('/book/');
|
||||
}
|
||||
|
||||
/// Resolve image bytes from an EPUB for the image viewer.
|
||||
/// The imageUrl may be a virtual epub:// URL or a relative path.
|
||||
Future<Uint8List?> resolveImageFromEpub({
|
||||
required String epubPath,
|
||||
required String imageUrl,
|
||||
required String fileHash,
|
||||
}) async {
|
||||
try {
|
||||
String relativePath;
|
||||
if (imageUrl.startsWith(virtualScheme)) {
|
||||
final uri = Uri.parse(imageUrl);
|
||||
final prefix = '/book/$fileHash/';
|
||||
if (!uri.path.startsWith(prefix)) return null;
|
||||
relativePath = Uri.decodeFull(uri.path).substring(prefix.length);
|
||||
} else {
|
||||
relativePath = imageUrl;
|
||||
}
|
||||
relativePath = relativePath.split('#')[0];
|
||||
|
||||
return await _streamService.readFileFromEpub(
|
||||
epubPath: epubPath,
|
||||
targetFilePath: relativePath,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a request is for a user-imported font.
|
||||
static bool isFontRequest(WebUri requestUrl) {
|
||||
return requestUrl.scheme == virtualScheme &&
|
||||
requestUrl.host == virtualDomain &&
|
||||
requestUrl.path.startsWith('/fonts/');
|
||||
}
|
||||
}
|
||||
100
lib/utils/epub/reader_dao.dart
Normal file
100
lib/utils/epub/reader_dao.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import '../database_helper.dart';
|
||||
|
||||
/// EPUB 阅读器数据访问层
|
||||
class ReaderDao {
|
||||
final DatabaseHelper _db = DatabaseHelper.instance;
|
||||
|
||||
// ─── reader_books ─────────────────────────────────────────────────
|
||||
|
||||
/// 获取所有未删除的阅读记录
|
||||
Future<List<Map<String, dynamic>>> getAllReaderBooks() async {
|
||||
final db = await _db.database;
|
||||
return db.query(
|
||||
'reader_books',
|
||||
where: 'is_deleted = 0',
|
||||
orderBy: 'updated_at DESC',
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据 ID 获取阅读记录
|
||||
Future<Map<String, dynamic>?> getReaderBookById(String id) async {
|
||||
final db = await _db.database;
|
||||
final results = await db.query(
|
||||
'reader_books',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
return results.isNotEmpty ? results.first : null;
|
||||
}
|
||||
|
||||
/// 插入阅读记录
|
||||
Future<int> insertReaderBook(Map<String, dynamic> book) async {
|
||||
final db = await _db.database;
|
||||
return db.insert('reader_books', book);
|
||||
}
|
||||
|
||||
/// 更新阅读记录字段
|
||||
Future<int> updateReaderBook(String id, Map<String, dynamic> fields) async {
|
||||
final db = await _db.database;
|
||||
return db.update(
|
||||
'reader_books',
|
||||
fields,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新阅读进度
|
||||
Future<int> updateReadingProgress(
|
||||
String id, String cfi, double percentage) async {
|
||||
final db = await _db.database;
|
||||
return db.update(
|
||||
'reader_books',
|
||||
{
|
||||
'last_read_cfi': cfi,
|
||||
'reading_percentage': percentage,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
/// 软删除
|
||||
Future<int> deleteReaderBook(String id) async {
|
||||
final db = await _db.database;
|
||||
return db.update(
|
||||
'reader_books',
|
||||
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
// ─── book_annotations ─────────────────────────────────────────────
|
||||
|
||||
/// 获取某本书的所有批注
|
||||
Future<List<Map<String, dynamic>>> getAnnotationsByBookId(
|
||||
String bookId) async {
|
||||
final db = await _db.database;
|
||||
return db.query(
|
||||
'book_annotations',
|
||||
where: 'book_id = ?',
|
||||
whereArgs: [bookId],
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
}
|
||||
|
||||
/// 插入批注
|
||||
Future<int> insertAnnotation(Map<String, dynamic> annotation) async {
|
||||
final db = await _db.database;
|
||||
return db.insert('book_annotations', annotation);
|
||||
}
|
||||
|
||||
/// 删除批注
|
||||
Future<int> deleteAnnotation(int id) async {
|
||||
final db = await _db.database;
|
||||
return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
}
|
||||
74
lib/utils/epub/reader_models.dart
Normal file
74
lib/utils/epub/reader_models.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
/// EPUB 解析结果数据模型
|
||||
library;
|
||||
|
||||
class EpubBookInfo {
|
||||
final String title;
|
||||
final String author;
|
||||
final List<String> authors;
|
||||
final String? description;
|
||||
final String? coverHref;
|
||||
final String opfRootPath;
|
||||
final String epubVersion;
|
||||
final List<SpineItem> spine;
|
||||
final List<TocEntry> toc;
|
||||
|
||||
EpubBookInfo({
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.authors,
|
||||
this.description,
|
||||
this.coverHref,
|
||||
required this.opfRootPath,
|
||||
required this.epubVersion,
|
||||
required this.spine,
|
||||
required this.toc,
|
||||
});
|
||||
}
|
||||
|
||||
class SpineItem {
|
||||
final int index;
|
||||
final String href;
|
||||
final String idref;
|
||||
final bool linear;
|
||||
|
||||
SpineItem({
|
||||
required this.index,
|
||||
required this.href,
|
||||
required this.idref,
|
||||
this.linear = true,
|
||||
});
|
||||
}
|
||||
|
||||
class TocEntry {
|
||||
final String label;
|
||||
final String href;
|
||||
final int spineIndex;
|
||||
final List<TocEntry> children;
|
||||
|
||||
TocEntry({
|
||||
required this.label,
|
||||
required this.href,
|
||||
this.spineIndex = -1,
|
||||
this.children = const [],
|
||||
});
|
||||
|
||||
/// 递归展平为列表(保留层级信息通过 depth)
|
||||
List<FlatTocItem> flatten() {
|
||||
final result = <FlatTocItem>[];
|
||||
_flattenRecursive(result, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
void _flattenRecursive(List<FlatTocItem> list, int depth) {
|
||||
list.add(FlatTocItem(entry: this, depth: depth));
|
||||
for (final child in children) {
|
||||
child._flattenRecursive(list, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FlatTocItem {
|
||||
final TocEntry entry;
|
||||
final int depth;
|
||||
FlatTocItem({required this.entry, required this.depth});
|
||||
}
|
||||
102
lib/utils/epub/reader_scripts.dart
Normal file
102
lib/utils/epub/reader_scripts.dart
Normal file
File diff suppressed because one or more lines are too long
142
lib/utils/epub/reader_settings.dart
Normal file
142
lib/utils/epub/reader_settings.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'epub_theme.dart';
|
||||
|
||||
/// Controls how the reader handles external link taps.
|
||||
enum ReaderLinkHandling { ask, always, never }
|
||||
|
||||
/// Controls the page-turning animation style.
|
||||
enum ReaderPageAnimation { none, slide }
|
||||
|
||||
class ReaderSettings {
|
||||
final double zoom;
|
||||
final bool followAppTheme;
|
||||
final double marginTop;
|
||||
final double marginBottom;
|
||||
final double marginLeft;
|
||||
final double marginRight;
|
||||
final ReaderLinkHandling linkHandling;
|
||||
final ReaderPageAnimation pageAnimation;
|
||||
|
||||
/// File name (with extension) of the user-imported font to use, or null to
|
||||
/// use the epub's own fonts.
|
||||
final String? fontFileName;
|
||||
|
||||
/// When true the custom font overrides the epub's own font-family rules.
|
||||
final bool overrideFontFamily;
|
||||
|
||||
/// When true, volume up/down keys turn pages in the reader.
|
||||
final bool volumeKeyTurnsPage;
|
||||
|
||||
const ReaderSettings({
|
||||
this.zoom = 1.0,
|
||||
this.followAppTheme = true,
|
||||
this.marginTop = 16.0,
|
||||
this.marginBottom = 16.0,
|
||||
this.marginLeft = 16.0,
|
||||
this.marginRight = 16.0,
|
||||
this.linkHandling = ReaderLinkHandling.ask,
|
||||
this.pageAnimation = ReaderPageAnimation.slide,
|
||||
this.fontFileName,
|
||||
this.overrideFontFamily = false,
|
||||
this.volumeKeyTurnsPage = false,
|
||||
});
|
||||
|
||||
// Sentinel: lets copyWith(fontFileName: null) mean "set to null" rather than
|
||||
// "leave unchanged". Used only for the nullable fontFileName field.
|
||||
static const Object _kUnset = Object();
|
||||
|
||||
ReaderSettings copyWith({
|
||||
double? zoom,
|
||||
bool? followAppTheme,
|
||||
double? marginTop,
|
||||
double? marginBottom,
|
||||
double? marginLeft,
|
||||
double? marginRight,
|
||||
ReaderLinkHandling? linkHandling,
|
||||
ReaderPageAnimation? pageAnimation,
|
||||
Object? fontFileName = _kUnset,
|
||||
bool? overrideFontFamily,
|
||||
bool? volumeKeyTurnsPage,
|
||||
}) {
|
||||
return ReaderSettings(
|
||||
zoom: zoom ?? this.zoom,
|
||||
followAppTheme: followAppTheme ?? this.followAppTheme,
|
||||
marginTop: marginTop ?? this.marginTop,
|
||||
marginBottom: marginBottom ?? this.marginBottom,
|
||||
marginLeft: marginLeft ?? this.marginLeft,
|
||||
marginRight: marginRight ?? this.marginRight,
|
||||
linkHandling: linkHandling ?? this.linkHandling,
|
||||
pageAnimation: pageAnimation ?? this.pageAnimation,
|
||||
fontFileName: identical(fontFileName, _kUnset)
|
||||
? this.fontFileName
|
||||
: fontFileName as String?,
|
||||
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
|
||||
volumeKeyTurnsPage: volumeKeyTurnsPage ?? this.volumeKeyTurnsPage,
|
||||
);
|
||||
}
|
||||
|
||||
EpubTheme toEpubTheme(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return EpubTheme(
|
||||
zoom: zoom,
|
||||
shouldOverrideTextColor: true,
|
||||
colorScheme: colorScheme,
|
||||
padding: EdgeInsets.only(
|
||||
top: marginTop,
|
||||
bottom: marginBottom,
|
||||
left: marginLeft,
|
||||
right: marginRight,
|
||||
),
|
||||
fontFileName: fontFileName,
|
||||
overrideFontFamily: overrideFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Persistence ====================
|
||||
|
||||
static const _kPrefix = 'reader_';
|
||||
|
||||
Future<void> save() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble('${_kPrefix}zoom', zoom);
|
||||
await prefs.setBool('${_kPrefix}followAppTheme', followAppTheme);
|
||||
await prefs.setDouble('${_kPrefix}marginTop', marginTop);
|
||||
await prefs.setDouble('${_kPrefix}marginBottom', marginBottom);
|
||||
await prefs.setDouble('${_kPrefix}marginLeft', marginLeft);
|
||||
await prefs.setDouble('${_kPrefix}marginRight', marginRight);
|
||||
await prefs.setInt('${_kPrefix}linkHandling', linkHandling.index);
|
||||
await prefs.setInt('${_kPrefix}pageAnimation', pageAnimation.index);
|
||||
if (fontFileName != null) {
|
||||
await prefs.setString('${_kPrefix}fontFileName', fontFileName!);
|
||||
} else {
|
||||
await prefs.remove('${_kPrefix}fontFileName');
|
||||
}
|
||||
await prefs.setBool('${_kPrefix}overrideFontFamily', overrideFontFamily);
|
||||
await prefs.setBool('${_kPrefix}volumeKeyTurnsPage', volumeKeyTurnsPage);
|
||||
}
|
||||
|
||||
static Future<ReaderSettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return ReaderSettings(
|
||||
zoom: prefs.getDouble('${_kPrefix}zoom') ?? 1.0,
|
||||
followAppTheme: prefs.getBool('${_kPrefix}followAppTheme') ?? true,
|
||||
marginTop: prefs.getDouble('${_kPrefix}marginTop') ?? 16.0,
|
||||
marginBottom: prefs.getDouble('${_kPrefix}marginBottom') ?? 16.0,
|
||||
marginLeft: prefs.getDouble('${_kPrefix}marginLeft') ?? 16.0,
|
||||
marginRight: prefs.getDouble('${_kPrefix}marginRight') ?? 16.0,
|
||||
linkHandling: ReaderLinkHandling.values[
|
||||
prefs.getInt('${_kPrefix}linkHandling') ??
|
||||
ReaderLinkHandling.ask.index],
|
||||
pageAnimation: ReaderPageAnimation.values[
|
||||
prefs.getInt('${_kPrefix}pageAnimation') ??
|
||||
ReaderPageAnimation.slide.index],
|
||||
fontFileName: prefs.getString('${_kPrefix}fontFileName'),
|
||||
overrideFontFamily:
|
||||
prefs.getBool('${_kPrefix}overrideFontFamily') ?? false,
|
||||
volumeKeyTurnsPage:
|
||||
prefs.getBool('${_kPrefix}volumeKeyTurnsPage') ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/utils/epub/volume_control_service.dart
Normal file
50
lib/utils/epub/volume_control_service.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 音量键翻页服务
|
||||
/// 注意:需要原生 Android 实现才能工作,当前为空操作
|
||||
class VolumeControlService {
|
||||
static const MethodChannel _methodChannel = MethodChannel(
|
||||
'mooknote/volume_control',
|
||||
);
|
||||
|
||||
static bool _available = false;
|
||||
static bool _checked = false;
|
||||
|
||||
static Future<void> enableInterception() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!_checked) await _checkAvailable();
|
||||
if (!_available) return;
|
||||
try {
|
||||
await _methodChannel.invokeMethod('enableInterception');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> disableInterception() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!_available) return;
|
||||
try {
|
||||
await _methodChannel.invokeMethod('disableInterception');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Stream<String> get volumeKeyEvents {
|
||||
if (!Platform.isAndroid || !_available) return const Stream.empty();
|
||||
// 需要原生 EventChannel 实现,当前返回空流
|
||||
return const Stream.empty();
|
||||
}
|
||||
|
||||
/// 检查原生端是否实现了该 channel
|
||||
static Future<void> _checkAvailable() async {
|
||||
_checked = true;
|
||||
try {
|
||||
await _methodChannel.invokeMethod('enableInterception');
|
||||
_available = true;
|
||||
} on MissingPluginException {
|
||||
_available = false;
|
||||
} catch (_) {
|
||||
_available = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
lib/utils/epub/web/reader_api.dart
Normal file
87
lib/utils/epub/web/reader_api.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'webview_bridge.dart';
|
||||
|
||||
/// Typed Dart mirror of the TypeScript `ReaderApi` interface
|
||||
/// (`web_assets/controller.js/api.ts`).
|
||||
///
|
||||
/// Every public method corresponds 1-to-1 with its TypeScript counterpart.
|
||||
/// The token parameter is managed internally by [WebViewBridge] — callers
|
||||
/// never touch raw token integers through this class.
|
||||
///
|
||||
/// Methods that return `Future<int>` fire the JS call and return a token the
|
||||
/// caller can later pass to [WebViewBridge.waitForEvent] / [waitForEvents]
|
||||
/// when it wants to batch-await multiple operations together.
|
||||
///
|
||||
/// Methods that return `Future<void>` fire the JS call and await its
|
||||
/// completion before returning.
|
||||
class ReaderApi {
|
||||
final WebViewBridge _bridge;
|
||||
|
||||
ReaderApi(this._bridge);
|
||||
|
||||
// ─── Token-based (deferred await) ──────────────────────────────────
|
||||
|
||||
/// Loads [url] into the iframe identified by [slot].
|
||||
/// [anchors] should be a JSON-encoded list: `'["id1","id2"]'`.
|
||||
Future<int> loadFrame(
|
||||
String slot,
|
||||
String url,
|
||||
String anchors,
|
||||
String properties,
|
||||
) => _bridge.call(
|
||||
(t) => "window.api.loadFrame($t, '$slot', '$url', $anchors, $properties)",
|
||||
);
|
||||
|
||||
/// Scrolls [slot]'s iframe to [pageIndex] without immediately awaiting.
|
||||
Future<int> jumpToPageFor(String slot, int pageIndex) =>
|
||||
_bridge.call((t) => "window.api.jumpToPageFor($t, '$slot', $pageIndex)");
|
||||
|
||||
/// Scrolls [slot]'s iframe to its last page without immediately awaiting.
|
||||
Future<int> jumpToLastPageOfFrame(String slot) =>
|
||||
_bridge.call((t) => "window.api.jumpToLastPageOfFrame($t, '$slot')");
|
||||
|
||||
/// Rotates the iframe triple in [direction] (`'next'` or `'prev'`).
|
||||
Future<int> cycleFrames(String direction) =>
|
||||
_bridge.call((t) => "window.api.cycleFrames($t, '$direction')");
|
||||
|
||||
// ─── Fire-and-await ────────────────────────────────────────────────
|
||||
|
||||
/// Scrolls the current iframe to [pageIndex] and awaits completion.
|
||||
Future<void> jumpToPage(int pageIndex) =>
|
||||
_bridge.callAndWait((t) => 'window.api.jumpToPage($t, $pageIndex)', 1000);
|
||||
|
||||
/// Restores the scroll position using a fractional [ratio] in [0,1].
|
||||
Future<void> restoreScrollPosition(double ratio) => _bridge.callAndWait(
|
||||
(t) => 'window.api.restoreScrollPosition($t, $ratio)',
|
||||
1000,
|
||||
);
|
||||
|
||||
/// Waits for the current frame to finish rendering.
|
||||
Future<void> waitForRender() =>
|
||||
_bridge.callAndWait((t) => 'window.api.waitForRender($t)', 1000);
|
||||
|
||||
/// Updates the reader theme/layout and awaits completion.
|
||||
///
|
||||
/// [theme] must be a JSON-serialisable map produced by `EpubTheme.toMap()`.
|
||||
Future<void> updateTheme(
|
||||
double viewWidth,
|
||||
double viewHeight,
|
||||
Map<String, dynamic> theme,
|
||||
) {
|
||||
final themeJson = jsonEncode(theme);
|
||||
return _bridge.callAndWait(
|
||||
(t) => 'window.api.updateTheme($t, $viewWidth, $viewHeight, $themeJson)',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fire-and-forget ───────────────────────────────────────────────
|
||||
|
||||
/// Checks whether there is an interactive element (image, etc.) at (x, y).
|
||||
Future<void> checkLongPressElementAt(double x, double y) =>
|
||||
_bridge.evaluate('window.api.checkLongPressElementAt($x, $y)');
|
||||
|
||||
/// Checks whether the tap at (x, y) hits a link, footnote, or other element.
|
||||
Future<void> checkTapElementAt(double x, double y) =>
|
||||
_bridge.evaluate('window.api.checkTapElementAt($x, $y)');
|
||||
}
|
||||
119
lib/utils/epub/web/webview_bridge.dart
Normal file
119
lib/utils/epub/web/webview_bridge.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
/// Manages JS↔Dart communication over an [InAppWebViewController].
|
||||
///
|
||||
/// Provides token-based async call tracking so that callers can fire a JS
|
||||
/// method that will eventually invoke `FlutterBridge.onEventFinished(token)`,
|
||||
/// and await the result on the Dart side via [waitForEvent].
|
||||
///
|
||||
/// Typical usage:
|
||||
/// ```dart
|
||||
/// // Fire and forget the token; caller awaits separately.
|
||||
/// final token = await _bridge.call((t) => "window.api.loadFrame($t, ...)");
|
||||
/// await _bridge.waitForEvent(token);
|
||||
///
|
||||
/// // Fire and immediately await.
|
||||
/// await _bridge.callAndWait((t) => "window.api.jumpToPage($t, $idx)");
|
||||
/// ```
|
||||
class WebViewBridge {
|
||||
InAppWebViewController? _controller;
|
||||
|
||||
int _currentToken = 0;
|
||||
final Map<int, Completer<void>> _completers = {};
|
||||
|
||||
// ─── Controller lifecycle ──────────────────────────────────────────
|
||||
|
||||
/// Attaches a live [InAppWebViewController]. Call this in `onWebViewCreated`.
|
||||
void attach(InAppWebViewController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
|
||||
/// Detaches the controller and cancels all pending completers.
|
||||
void detach() {
|
||||
_controller = null;
|
||||
for (final completer in _completers.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(StateError('WebViewBridge detached'));
|
||||
}
|
||||
}
|
||||
_completers.clear();
|
||||
}
|
||||
|
||||
// ─── JS evaluation ─────────────────────────────────────────────────
|
||||
|
||||
/// Evaluates [source] in the WebView. No-ops if no controller is attached.
|
||||
Future<void> evaluate(String source) async {
|
||||
await _controller?.evaluateJavascript(source: source);
|
||||
}
|
||||
|
||||
// ─── Token management ──────────────────────────────────────────────
|
||||
|
||||
/// Allocates a new token and registers a [Completer] for it.
|
||||
///
|
||||
/// Embed the returned token in the JS call so JS can resolve it via
|
||||
/// `FlutterBridge.onEventFinished(token)`.
|
||||
int issueToken() {
|
||||
_currentToken++;
|
||||
_completers[_currentToken] = Completer<void>();
|
||||
return _currentToken;
|
||||
}
|
||||
|
||||
/// Called by the `onEventFinished` JS handler to resolve a pending token.
|
||||
///
|
||||
/// A [token] of `-1` is a sentinel for fire-and-forget notifications that
|
||||
/// do not need to be tracked.
|
||||
void resolveToken(int token) {
|
||||
if (token == -1) return;
|
||||
final completer = _completers.remove(token);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Awaiting ──────────────────────────────────────────────────────
|
||||
|
||||
/// Waits for [token] to be resolved, or times out after [timeoutMs] ms.
|
||||
Future<void> waitForEvent(int token, [int timeoutMs = 10000]) async {
|
||||
final completer = _completers[token];
|
||||
if (completer == null) {
|
||||
debugPrint('WebViewBridge: no completer for token $token');
|
||||
return;
|
||||
}
|
||||
return completer.future.timeout(
|
||||
Duration(milliseconds: timeoutMs),
|
||||
onTimeout: () {
|
||||
_completers.remove(token);
|
||||
debugPrint('WebViewBridge: timeout for token $token');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Waits for all [tokens] to be resolved concurrently.
|
||||
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
|
||||
await Future.wait(tokens.map((t) => waitForEvent(t, timeoutMs)));
|
||||
}
|
||||
|
||||
// ─── Convenience helpers ───────────────────────────────────────────
|
||||
|
||||
/// Issues a token, evaluates the JS returned by [source], and returns the
|
||||
/// token so the caller can [waitForEvent] later.
|
||||
Future<int> call(String Function(int token) source) async {
|
||||
final token = issueToken();
|
||||
await evaluate(source(token));
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Issues a token, evaluates the JS returned by [source], and immediately
|
||||
/// awaits [waitForEvent] before returning.
|
||||
Future<void> callAndWait(
|
||||
String Function(int token) source, [
|
||||
int timeoutMs = 10000,
|
||||
]) async {
|
||||
final token = issueToken();
|
||||
await evaluate(source(token));
|
||||
await waitForEvent(token, timeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import '../../models/book_annotation.dart';
|
||||
import '../database_helper.dart';
|
||||
|
||||
/// 书籍批注数据访问对象
|
||||
class BookAnnotationDao {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
||||
|
||||
Future<Database> get _db async => await _dbHelper.database;
|
||||
|
||||
/// 插入批注,返回插入的 id
|
||||
Future<int> insert(BookAnnotation annotation) async {
|
||||
final db = await _db;
|
||||
return await db.insert('book_annotations', annotation.toMap()..remove('id'));
|
||||
}
|
||||
|
||||
/// 更新批注
|
||||
Future<void> update(BookAnnotation annotation) async {
|
||||
final db = await _db;
|
||||
await db.update(
|
||||
'book_annotations',
|
||||
annotation.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [annotation.id],
|
||||
);
|
||||
}
|
||||
|
||||
/// 保存(有 id 则更新,无 id 则插入)
|
||||
Future<BookAnnotation> save(BookAnnotation annotation) async {
|
||||
if (annotation.id != null) {
|
||||
await update(annotation);
|
||||
return annotation;
|
||||
}
|
||||
final id = await insert(annotation);
|
||||
return annotation.copyWith(id: id);
|
||||
}
|
||||
|
||||
/// 根据 id 删除
|
||||
Future<void> deleteById(int id) async {
|
||||
final db = await _db;
|
||||
await db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// 根据 CFI 删除(用于删除高亮/下划线)
|
||||
Future<void> deleteByCfi(String bookId, String cfi) async {
|
||||
final db = await _db;
|
||||
await db.delete(
|
||||
'book_annotations',
|
||||
where: 'book_id = ? AND cfi = ?',
|
||||
whereArgs: [bookId, cfi],
|
||||
);
|
||||
}
|
||||
|
||||
/// 查询某本书的所有批注
|
||||
Future<List<BookAnnotation>> getByBookId(String bookId) async {
|
||||
final db = await _db;
|
||||
final maps = await db.query(
|
||||
'book_annotations',
|
||||
where: 'book_id = ?',
|
||||
whereArgs: [bookId],
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
return maps.map((m) => BookAnnotation.fromMap(m)).toList();
|
||||
}
|
||||
|
||||
/// 查询某本书的某种类型批注
|
||||
Future<List<BookAnnotation>> getByBookIdAndType(String bookId, String type) async {
|
||||
final db = await _db;
|
||||
final maps = await db.query(
|
||||
'book_annotations',
|
||||
where: 'book_id = ? AND type = ?',
|
||||
whereArgs: [bookId, type],
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
return maps.map((m) => BookAnnotation.fromMap(m)).toList();
|
||||
}
|
||||
|
||||
/// 查询某本书的所有书签
|
||||
Future<List<BookAnnotation>> getBookmarks(String bookId) async {
|
||||
return getByBookIdAndType(bookId, 'bookmark');
|
||||
}
|
||||
|
||||
/// 查询某本书的所有高亮/下划线
|
||||
Future<List<BookAnnotation>> getAnnotations(String bookId) async {
|
||||
final db = await _db;
|
||||
final maps = await db.query(
|
||||
'book_annotations',
|
||||
where: "book_id = ? AND type IN ('highlight', 'underline')",
|
||||
whereArgs: [bookId],
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
return maps.map((m) => BookAnnotation.fromMap(m)).toList();
|
||||
}
|
||||
|
||||
/// 根据 id 查询
|
||||
Future<BookAnnotation?> getById(int id) async {
|
||||
final db = await _db;
|
||||
final maps = await db.query(
|
||||
'book_annotations',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (maps.isEmpty) return null;
|
||||
return BookAnnotation.fromMap(maps.first);
|
||||
}
|
||||
|
||||
/// 获取某本书的批注数量
|
||||
Future<int> getCount(String bookId) async {
|
||||
final db = await _db;
|
||||
final result = await db.rawQuery(
|
||||
'SELECT COUNT(*) as cnt FROM book_annotations WHERE book_id = ?',
|
||||
[bookId],
|
||||
);
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 阅读器文件路径管理
|
||||
class BookFileHelper {
|
||||
static final BookFileHelper instance = BookFileHelper._init();
|
||||
BookFileHelper._init();
|
||||
|
||||
String? _rootPath;
|
||||
|
||||
Future<String> get _root async {
|
||||
if (_rootPath != null) return _rootPath!;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_rootPath = p.join(dir.path, 'mooknote', 'book_file');
|
||||
await Directory(_rootPath!).create(recursive: true);
|
||||
return _rootPath!;
|
||||
}
|
||||
|
||||
Future<String> get bookFileRoot async => _root;
|
||||
|
||||
Future<String> get coverDir async {
|
||||
final root = await _root;
|
||||
final dir = p.join(root, 'cover');
|
||||
await Directory(dir).create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
Future<String> bookDir(String bookId) async {
|
||||
final root = await _root;
|
||||
final dir = p.join(root, bookId);
|
||||
await Directory(dir).create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
Future<String> bookFile(String bookId, String fileName) async {
|
||||
final dir = await bookDir(bookId);
|
||||
return p.join(dir, fileName);
|
||||
}
|
||||
|
||||
String? relativePath(String absolutePath) {
|
||||
if (_rootPath == null) return null;
|
||||
if (absolutePath.startsWith(_rootPath!)) {
|
||||
return absolutePath.substring(_rootPath!.length + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> absolutePath(String relativePath) async {
|
||||
final root = await _root;
|
||||
return p.join(root, relativePath);
|
||||
}
|
||||
|
||||
Future<void> deleteBookFiles(String bookId) async {
|
||||
final dir = await bookDir(bookId);
|
||||
if (await Directory(dir).exists()) {
|
||||
await Directory(dir).delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步初始化(必须在使用 resolveAbsolutePath 前调用一次 bookFileRoot)
|
||||
Future<void> ensureInitialized() async {
|
||||
await _root;
|
||||
}
|
||||
|
||||
/// 根据相对路径解析绝对路径(调用前需确保已初始化)
|
||||
String resolveAbsolutePath(String relativePath) {
|
||||
if (_rootPath == null) return relativePath;
|
||||
return p.join(_rootPath!, relativePath);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/// 将归一化坐标 (0-1) 映射到 3x3 九宫格区域 (0-8)
|
||||
///
|
||||
/// ```
|
||||
/// 0 1 2
|
||||
/// 3 4 5
|
||||
/// 6 7 8
|
||||
/// ```
|
||||
int coordinatesToPart(double x, double y) {
|
||||
final col = x < 0.33 ? 0 : (x < 0.66 ? 1 : 2);
|
||||
final row = y < 0.33 ? 0 : (y < 0.66 ? 1 : 2);
|
||||
return row * 3 + col;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../models/reader_book.dart';
|
||||
import '../database_helper.dart';
|
||||
|
||||
/// 阅读器书籍 DAO
|
||||
class ReaderBookDao {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
||||
|
||||
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
debugPrint('[ReaderBookDao] $op error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ReaderBook>> getAllReaderBooks() => _wrap('getAllReaderBooks', () async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'reader_books',
|
||||
where: 'is_deleted = ?',
|
||||
whereArgs: [0],
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
return List.generate(maps.length, (i) => ReaderBook.fromJson(maps[i]));
|
||||
});
|
||||
|
||||
Future<ReaderBook?> getReaderBookById(String id) => _wrap('getReaderBookById', () async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'reader_books',
|
||||
where: 'id = ? AND is_deleted = ?',
|
||||
whereArgs: [id, 0],
|
||||
);
|
||||
if (maps.isEmpty) return null;
|
||||
return ReaderBook.fromJson(maps.first);
|
||||
});
|
||||
|
||||
Future<void> insertReaderBook(ReaderBook book) => _wrap('insertReaderBook', () async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert('reader_books', book.toJson());
|
||||
});
|
||||
|
||||
Future<void> updateReaderBook(ReaderBook book) => _wrap('updateReaderBook', () async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.update(
|
||||
'reader_books',
|
||||
book.toJson(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [book.id],
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> deleteReaderBook(String id) => _wrap('deleteReaderBook', () async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.update(
|
||||
'reader_books',
|
||||
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> permanentDeleteReaderBook(String id) => _wrap('permanentDeleteReaderBook', () async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('reader_books', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import '../../service/book_server.dart';
|
||||
|
||||
/// 生成 foliate-js 阅读器 URL
|
||||
String generateReaderUrl({
|
||||
required String fileUrl,
|
||||
String cfi = '',
|
||||
required String backgroundColor,
|
||||
required String textColor,
|
||||
bool isDarkMode = false,
|
||||
}) {
|
||||
final indexHtmlPath = 'http://127.0.0.1:${Server().port}/foliate-js/index.html';
|
||||
|
||||
final jsBg = _convertDartColorToJs(backgroundColor);
|
||||
final jsTc = _convertDartColorToJs(textColor);
|
||||
|
||||
final style = {
|
||||
'fontSize': 100,
|
||||
'fontName': '',
|
||||
'fontPath': '',
|
||||
'fontWeight': 400,
|
||||
'letterSpacing': 0,
|
||||
'spacing': 1.6,
|
||||
'paragraphSpacing': 0.6,
|
||||
'textIndent': 2,
|
||||
'fontColor': '#$jsTc',
|
||||
'backgroundColor': '#$jsBg',
|
||||
'topMargin': 25,
|
||||
'bottomMargin': 25,
|
||||
'sideMargin': 3,
|
||||
'justify': true,
|
||||
'hyphenate': false,
|
||||
'pageTurnStyle': 'slide',
|
||||
'maxColumnCount': 1,
|
||||
'columnThreshold': 3,
|
||||
'writingMode': 'horizontal-tb',
|
||||
'textAlign': 'justify',
|
||||
'backgroundImage': '',
|
||||
'bgimgBlur': 0,
|
||||
'bgimgOpacity': 1.0,
|
||||
'bgimgFit': 'cover',
|
||||
'allowScript': false,
|
||||
'customCSS': '',
|
||||
'customCSSEnabled': false,
|
||||
'useBookStyles': true,
|
||||
'headingFontSize': 130,
|
||||
'codeHighlightTheme': 'atom-one-light',
|
||||
};
|
||||
|
||||
final params = {
|
||||
'importing': false,
|
||||
'url': fileUrl,
|
||||
'initialCfi': cfi,
|
||||
'style': style,
|
||||
};
|
||||
|
||||
final queryParts = params.entries
|
||||
.map((e) => '${e.key}=${Uri.encodeComponent(jsonEncode(e.value))}')
|
||||
.join('&');
|
||||
|
||||
return '$indexHtmlPath?$queryParts';
|
||||
}
|
||||
|
||||
/// 将 Dart 的 ARGB hex (FFRRGGBB) 转成 CSS 的 #RRGGBB 格式
|
||||
String _convertDartColorToJs(String dartColor) {
|
||||
if (dartColor.startsWith('#')) {
|
||||
dartColor = dartColor.substring(1);
|
||||
}
|
||||
if (dartColor.length == 8) {
|
||||
return '#${dartColor.substring(2)}';
|
||||
}
|
||||
return '#$dartColor';
|
||||
}
|
||||
@@ -201,4 +201,10 @@ class UserPrefs {
|
||||
/// 已忽略的版本号(不再提示更新)
|
||||
String get dismissedVersion => prefs.getString('dismissedVersion') ?? '';
|
||||
Future<bool> setDismissedVersion(String value) => prefs.setString('dismissedVersion', value);
|
||||
|
||||
// ========== EPUB 阅读器 ==========
|
||||
|
||||
/// EPUB 阅读器字体大小
|
||||
double get epubFontSize => prefs.getDouble('epubFontSize') ?? 18.0;
|
||||
Future<bool> setEpubFontSize(double value) => prefs.setDouble('epubFontSize', value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user