Files
MookNote/lib/utils/epub/epub_parser.dart
2026-06-27 14:06:44 +08:00

478 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}
}