608 lines
19 KiB
Objective-C
608 lines
19 KiB
Objective-C
//
|
|
// WREpubParser.m
|
|
// WeRead (读书)
|
|
// Reverse-engineered implementation reconstruction
|
|
//
|
|
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
|
|
// and contextual knowledge of EPUB file format handling.
|
|
//
|
|
|
|
#import "WREpubParser.h"
|
|
#import "WRBook.h"
|
|
#import "WHAlbumInfo.h"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
NSString *const WREpubParserErrorDomain = @"WREpubParserErrorDomain";
|
|
|
|
static NSString *const kContainerXMLPath = @"META-INF/container.xml";
|
|
static NSString *const kOPFMIMEType = @"application/oebps-package+xml";
|
|
static NSString *const kNCXMIMEType = @"application/x-dtbncx+xml";
|
|
|
|
#pragma mark - Private Interface
|
|
|
|
@interface WREpubParser ()
|
|
|
|
@property (nonatomic, copy, readwrite) NSString *epubFilePath;
|
|
@property (nonatomic, copy, readwrite) NSString *baseDirectory;
|
|
@property (nonatomic, strong, readwrite, nullable) NSError *lastError;
|
|
@property (nonatomic, strong, readwrite) NSArray<NSDictionary *> *chapters;
|
|
@property (nonatomic, strong, readwrite) NSDictionary<NSString *, NSString *> *resourceMap;
|
|
@property (nonatomic, strong, readwrite) NSArray<NSString *> *spineItemIDs;
|
|
|
|
@end
|
|
|
|
#pragma mark - Implementation
|
|
|
|
@implementation WREpubParser
|
|
{
|
|
// Ivars confirmed from binary analysis:
|
|
NSString *_epubFilePath;
|
|
NSString *_baseDirectory;
|
|
NSError *_lastError;
|
|
UIViewController *_epubController; // weak, assigned from delegate
|
|
WRBook *_book;
|
|
WHAlbumInfo *_albumInfo;
|
|
|
|
// Internal caches (not exported in header but inferred):
|
|
NSMutableDictionary<NSString *, NSString *> *_manifestMap; // id -> href
|
|
NSMutableDictionary<NSString *, NSString *> *_mediaTypeMap; // id -> media-type
|
|
NSMutableArray<NSString *> *_spineRefs; // idref list
|
|
}
|
|
|
|
#pragma mark - Lifecycle
|
|
|
|
- (instancetype)initWithFilePath:(NSString *)path
|
|
book:(nullable WRBook *)book
|
|
{
|
|
self = [super init];
|
|
if (self) {
|
|
_epubFilePath = [path copy];
|
|
_book = book;
|
|
_manifestMap = [NSMutableDictionary dictionary];
|
|
_mediaTypeMap = [NSMutableDictionary dictionary];
|
|
_spineRefs = [NSMutableArray array];
|
|
_chapters = @[];
|
|
_resourceMap = @{};
|
|
_spineItemIDs = @[];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
#pragma mark - Public: Parsing
|
|
|
|
- (BOOL)parse:(NSError *_Nullable *_Nullable)error
|
|
{
|
|
// 1. Locate the OPF path from container.xml
|
|
NSError *containerError = nil;
|
|
NSString *opfRelPath = [self parseContainerXML:&containerError];
|
|
if (!opfRelPath) {
|
|
[self _setError:error
|
|
code:WREpubParserErrorContainerParseFail
|
|
description:@"Failed to parse container.xml"
|
|
underlyingError:containerError];
|
|
return NO;
|
|
}
|
|
|
|
// 2. Parse the OPF to populate manifest, spine, and metadata
|
|
NSError *opfError = nil;
|
|
if (![self parseOPFAtRelativePath:opfRelPath error:&opfError]) {
|
|
[self _setError:error
|
|
code:WREpubParserErrorOPFParseFail
|
|
description:@"Failed to parse content.opf"
|
|
underlyingError:opfError];
|
|
return NO;
|
|
}
|
|
|
|
// 3. Optionally parse NCX for table of contents
|
|
NSError *ncxError = nil;
|
|
[self parseNCX:&ncxError];
|
|
// NCX failure is non-fatal; log but continue
|
|
if (ncxError) {
|
|
NSLog(@"[WREpubParser] NCX parse warning: %@", ncxError);
|
|
}
|
|
|
|
// 4. Build the chapter list from the spine
|
|
[self _buildChapterList];
|
|
|
|
// 5. Build the resource map from the manifest
|
|
[self _buildResourceMap];
|
|
|
|
if (self.chapters.count == 0) {
|
|
[self _setError:error
|
|
code:WREpubParserErrorSpineEmpty
|
|
description:@"Spine contains no items"
|
|
underlyingError:nil];
|
|
return NO;
|
|
}
|
|
|
|
return YES;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parse META-INF/container.xml
|
|
// ---------------------------------------------------------------------------
|
|
- (nullable NSString *)parseContainerXML:(NSError *_Nullable *_Nullable)error
|
|
{
|
|
NSString *containerPath =
|
|
[self.epubFilePath stringByAppendingPathComponent:kContainerXMLPath];
|
|
|
|
// container.xml is always plain text (unencrypted)
|
|
NSData *data = [NSData dataWithContentsOfFile:containerPath options:0 error:error];
|
|
if (!data) return nil;
|
|
|
|
// Use NSXMLParser to extract the rootfile full-path
|
|
//
|
|
// Expected structure:
|
|
// <container>
|
|
// <rootfiles>
|
|
// <rootfile full-path="OEBPS/content.opf" media-type="..."/>
|
|
// </rootfiles>
|
|
// </container>
|
|
|
|
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
|
|
WREpubContainerParserDelegate *delegate =
|
|
[[WREpubContainerParserDelegate alloc] init];
|
|
parser.delegate = delegate;
|
|
|
|
if (![parser parse]) {
|
|
if (error) *error = parser.parserError;
|
|
return nil;
|
|
}
|
|
|
|
return delegate.rootFilePath;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parse the OPF (content.opf) file
|
|
// ---------------------------------------------------------------------------
|
|
- (BOOL)parseOPFAtRelativePath:(NSString *)opfRelPath
|
|
error:(NSError *_Nullable *_Nullable)error
|
|
{
|
|
NSString *opfFullPath =
|
|
[self.epubFilePath stringByAppendingPathComponent:opfRelPath];
|
|
|
|
// Set the base directory for resolving relative paths within the OPF
|
|
_baseDirectory = [opfFullPath stringByDeletingLastPathComponent];
|
|
|
|
NSData *data = [NSData dataWithContentsOfFile:opfFullPath options:0 error:error];
|
|
if (!data) return NO;
|
|
|
|
// Use NSXMLParser to walk the OPF XML
|
|
//
|
|
// Sections to parse:
|
|
// <manifest> -> populate _manifestMap and _mediaTypeMap
|
|
// <spine> -> populate _spineRefs (ordered idref list)
|
|
// <metadata> -> extract title, identifier, etc. for WRBook
|
|
|
|
WREpubOPFParserDelegate *opfDelegate =
|
|
[[WREpubOPFParserDelegate alloc] initWithBaseDirectory:_baseDirectory];
|
|
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
|
|
parser.delegate = opfDelegate;
|
|
|
|
if (![parser parse]) {
|
|
if (error) *error = parser.parserError;
|
|
return NO;
|
|
}
|
|
|
|
// Transfer parsed data
|
|
[_manifestMap setDictionary:opfDelegate.manifestItems];
|
|
[_mediaTypeMap setDictionary:opfDelegate.mediaTypes];
|
|
[_spineRefs setArray:opfDelegate.spineItemRefs];
|
|
|
|
// Extract metadata into _book if available
|
|
if (opfDelegate.bookTitle) {
|
|
_book.title = opfDelegate.bookTitle;
|
|
}
|
|
|
|
return YES;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parse toc.ncx for table of contents
|
|
// ---------------------------------------------------------------------------
|
|
- (nullable NSArray *)parseNCX:(NSError *_Nullable *_Nullable)error
|
|
{
|
|
// Find the NCX item in the manifest (media-type = application/x-dtbncx+xml)
|
|
NSString *ncxID = nil;
|
|
for (NSString *itemID in _mediaTypeMap) {
|
|
if ([_mediaTypeMap[itemID] isEqualToString:kNCXMIMEType]) {
|
|
ncxID = itemID;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!ncxID) {
|
|
// Some EPUBs use nav.xhtml instead of NCX (EPUB3)
|
|
// Try finding nav document
|
|
return nil;
|
|
}
|
|
|
|
NSString *ncxRelPath = _manifestMap[ncxID];
|
|
if (!ncxRelPath) return nil;
|
|
|
|
NSString *ncxFullPath =
|
|
[_baseDirectory stringByAppendingPathComponent:ncxRelPath];
|
|
|
|
NSData *data = [NSData dataWithContentsOfFile:ncxFullPath options:0 error:error];
|
|
if (!data) return nil;
|
|
|
|
// Parse NCX XML:
|
|
// <ncx>
|
|
// <navMap>
|
|
// <navPoint id="..." playOrder="1">
|
|
// <navLabel><text>Chapter 1</text></navLabel>
|
|
// <content src="chapter1.xhtml"/>
|
|
// <navPoint> ... nested ... </navPoint>
|
|
// </navPoint>
|
|
// </navMap>
|
|
// </ncx>
|
|
|
|
WREpubNCXParserDelegate *ncxDelegate = [[WREpubNCXParserDelegate alloc] init];
|
|
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
|
|
parser.delegate = ncxDelegate;
|
|
|
|
if (![parser parse]) {
|
|
if (error) *error = parser.parserError;
|
|
return nil;
|
|
}
|
|
|
|
return ncxDelegate.tocEntries;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Return chapter content (XHTML) for a given index
|
|
// ---------------------------------------------------------------------------
|
|
- (nullable NSString *)contentForChapterAtIndex:(NSUInteger)index
|
|
error:(NSError *_Nullable *_Nullable)error
|
|
{
|
|
if (index >= self.chapters.count) return nil;
|
|
|
|
NSDictionary *chapterInfo = self.chapters[index];
|
|
NSString *href = chapterInfo[@"href"];
|
|
if (!href) return nil;
|
|
|
|
NSString *fullPath =
|
|
[_baseDirectory stringByAppendingPathComponent:href];
|
|
|
|
// If the file is encrypted, it must be decrypted first via WREncryptedFileManager
|
|
NSData *data = [NSData dataWithContentsOfFile:fullPath options:0 error:error];
|
|
if (!data) return nil;
|
|
|
|
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resolve a relative resource path
|
|
// ---------------------------------------------------------------------------
|
|
- (nullable NSString *)absolutePathForResource:(NSString *)relativePath
|
|
{
|
|
if (!relativePath) return nil;
|
|
|
|
// Try the resource map first
|
|
NSString *mapped = self.resourceMap[relativePath];
|
|
if (mapped) return mapped;
|
|
|
|
// Fallback: resolve relative to the base directory
|
|
return [_baseDirectory stringByAppendingPathComponent:relativePath];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delegate callback
|
|
// ---------------------------------------------------------------------------
|
|
- (void)notifyDelegateOfError:(NSError *)error
|
|
{
|
|
_lastError = error;
|
|
|
|
// The delegate protocol method uses epubController:didFailWithError:
|
|
// The "controller" here is the UIViewController that owns the reader.
|
|
if ([self.delegate respondsToSelector:@selector(epubController:didFailWithError:)]) {
|
|
[self.delegate epubController:_epubController didFailWithError:error];
|
|
}
|
|
}
|
|
|
|
#pragma mark - Private Helpers
|
|
|
|
/// Build the chapters array from spine references + manifest.
|
|
- (void)_buildChapterList
|
|
{
|
|
NSMutableArray *chapters = [NSMutableArray arrayWithCapacity:_spineRefs.count];
|
|
|
|
for (NSString *idref in _spineRefs) {
|
|
NSString *href = _manifestMap[idref];
|
|
NSString *mediaType = _mediaTypeMap[idref];
|
|
|
|
if (!href) continue;
|
|
|
|
NSMutableDictionary *chapterInfo = [NSMutableDictionary dictionary];
|
|
chapterInfo[@"id"] = idref;
|
|
chapterInfo[@"href"] = href;
|
|
chapterInfo[@"mediaType"] = mediaType ?: @"application/xhtml+xml";
|
|
chapterInfo[@"fullPath"] =
|
|
[_baseDirectory stringByAppendingPathComponent:href];
|
|
|
|
[chapters addObject:[chapterInfo copy]];
|
|
}
|
|
|
|
_chapters = [chapters copy];
|
|
}
|
|
|
|
/// Build a flat resource map for images, CSS, fonts, etc.
|
|
- (void)_buildResourceMap
|
|
{
|
|
NSMutableDictionary *map = [NSMutableDictionary dictionary];
|
|
|
|
for (NSString *itemID in _manifestMap) {
|
|
NSString *href = _manifestMap[itemID];
|
|
if (!href) continue;
|
|
|
|
NSString *absPath =
|
|
[_baseDirectory stringByAppendingPathComponent:href];
|
|
map[href] = absPath;
|
|
|
|
// Also index by filename for convenience
|
|
NSString *filename = [href lastPathComponent];
|
|
if (filename) {
|
|
map[filename] = absPath;
|
|
}
|
|
}
|
|
|
|
_resourceMap = [map copy];
|
|
}
|
|
|
|
/// Helper to set the error pointer and store lastError.
|
|
- (void)_setError:(NSError *_Nullable *_Nullable)outError
|
|
code:(WREpubParserErrorCode)code
|
|
description:(NSString *)description
|
|
underlyingError:(nullable NSError *)underlyingError
|
|
{
|
|
NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
|
|
userInfo[NSLocalizedDescriptionKey] = description;
|
|
if (underlyingError) {
|
|
userInfo[NSUnderlyingErrorKey] = underlyingError;
|
|
}
|
|
|
|
NSError *err = [NSError errorWithDomain:WREpubParserErrorDomain
|
|
code:code
|
|
userInfo:userInfo];
|
|
_lastError = err;
|
|
if (outError) *outError = err;
|
|
}
|
|
|
|
@end
|
|
|
|
// ===========================================================================
|
|
// Internal XML Parser Delegates (file-private)
|
|
// ===========================================================================
|
|
|
|
#pragma mark - Container Parser Delegate
|
|
|
|
/// Parses META-INF/container.xml to extract the rootfile path.
|
|
@interface WREpubContainerParserDelegate : NSObject <NSXMLParserDelegate>
|
|
@property (nonatomic, copy, nullable) NSString *rootFilePath;
|
|
@end
|
|
|
|
@implementation WREpubContainerParserDelegate
|
|
{
|
|
BOOL _insideRootfile;
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
didStartElement:(NSString *)elementName
|
|
namespaceURI:(NSString *)namespaceURI
|
|
qualifiedName:(NSString *)qName
|
|
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
|
|
{
|
|
if ([elementName isEqualToString:@"rootfile"]) {
|
|
_rootFilePath = attributeDict[@"full-path"];
|
|
_insideRootfile = YES;
|
|
}
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
didEndElement:(NSString *)elementName
|
|
namespaceURI:(NSString *)namespaceURI
|
|
qualifiedName:(NSString *)qName
|
|
{
|
|
if ([elementName isEqualToString:@"rootfile"]) {
|
|
_insideRootfile = NO;
|
|
}
|
|
}
|
|
|
|
@end
|
|
|
|
#pragma mark - OPF Parser Delegate
|
|
|
|
/// Parses the OPF file: manifest, spine, and metadata sections.
|
|
@interface WREpubOPFParserDelegate : NSObject <NSXMLParserDelegate>
|
|
|
|
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *manifestItems;
|
|
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *mediaTypes;
|
|
@property (nonatomic, strong) NSMutableArray<NSString *> *spineItemRefs;
|
|
@property (nonatomic, copy, nullable) NSString *bookTitle;
|
|
|
|
- (instancetype)initWithBaseDirectory:(NSString *)baseDir;
|
|
|
|
@end
|
|
|
|
@implementation WREpubOPFParserDelegate
|
|
{
|
|
NSString *_baseDirectory;
|
|
BOOL _inMetadata;
|
|
BOOL _inManifest;
|
|
BOOL _inSpine;
|
|
NSMutableString *_currentText;
|
|
}
|
|
|
|
- (instancetype)initWithBaseDirectory:(NSString *)baseDir
|
|
{
|
|
self = [super init];
|
|
if (self) {
|
|
_baseDirectory = baseDir;
|
|
_manifestItems = [NSMutableDictionary dictionary];
|
|
_mediaTypes = [NSMutableDictionary dictionary];
|
|
_spineItemRefs = [NSMutableArray array];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
didStartElement:(NSString *)elementName
|
|
namespaceURI:(NSString *)namespaceURI
|
|
qualifiedName:(NSString *)qName
|
|
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
|
|
{
|
|
if ([elementName isEqualToString:@"metadata"]) {
|
|
_inMetadata = YES;
|
|
} else if ([elementName isEqualToString:@"manifest"]) {
|
|
_inManifest = YES;
|
|
} else if ([elementName isEqualToString:@"spine"]) {
|
|
_inSpine = YES;
|
|
}
|
|
|
|
if (_inManifest && [elementName isEqualToString:@"item"]) {
|
|
NSString *itemId = attributeDict[@"id"];
|
|
NSString *href = attributeDict[@"href"];
|
|
NSString *mediaType = attributeDict[@"media-type"];
|
|
if (itemId && href) {
|
|
_manifestItems[itemId] = href;
|
|
if (mediaType) {
|
|
_mediaTypes[itemId] = mediaType;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_inSpine && [elementName isEqualToString:@"itemref"]) {
|
|
NSString *idref = attributeDict[@"idref"];
|
|
if (idref) {
|
|
[_spineItemRefs addObject:idref];
|
|
}
|
|
}
|
|
|
|
_currentText = [NSMutableString string];
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
foundCharacters:(NSString *)string
|
|
{
|
|
[_currentText appendString:string];
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
didEndElement:(NSString *)elementName
|
|
namespaceURI:(NSString *)namespaceURI
|
|
qualifiedName:(NSString *)qName
|
|
{
|
|
if ([elementName isEqualToString:@"metadata"]) {
|
|
_inMetadata = NO;
|
|
} else if ([elementName isEqualToString:@"manifest"]) {
|
|
_inManifest = NO;
|
|
} else if ([elementName isEqualToString:@"spine"]) {
|
|
_inSpine = NO;
|
|
}
|
|
|
|
// Extract <dc:title> from metadata
|
|
if (_inMetadata && [elementName isEqualToString:@"dc:title"]) {
|
|
_bookTitle = [_currentText stringByTrimmingCharactersInSet:
|
|
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
}
|
|
|
|
_currentText = nil;
|
|
}
|
|
|
|
@end
|
|
|
|
#pragma mark - NCX Parser Delegate
|
|
|
|
/// Parses toc.ncx to extract the table of contents tree.
|
|
@interface WREpubNCXParserDelegate : NSObject <NSXMLParserDelegate>
|
|
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *tocEntries;
|
|
@end
|
|
|
|
@implementation WREpubNCXParserDelegate
|
|
{
|
|
BOOL _inNavPoint;
|
|
BOOL _inNavLabel;
|
|
BOOL _inContent;
|
|
BOOL _inText;
|
|
NSString *_currentNavPointId;
|
|
NSString *_currentLabel;
|
|
NSString *_currentSrc;
|
|
NSMutableString *_currentText;
|
|
NSUInteger _playOrder;
|
|
}
|
|
|
|
- (instancetype)init
|
|
{
|
|
self = [super init];
|
|
if (self) {
|
|
_tocEntries = [NSMutableArray array];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
didStartElement:(NSString *)elementName
|
|
namespaceURI:(NSString *)namespaceURI
|
|
qualifiedName:(NSString *)qName
|
|
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
|
|
{
|
|
_currentText = [NSMutableString string];
|
|
|
|
if ([elementName isEqualToString:@"navPoint"]) {
|
|
_inNavPoint = YES;
|
|
_currentNavPointId = attributeDict[@"id"];
|
|
_playOrder = [attributeDict[@"playOrder"] integerValue];
|
|
} else if ([elementName isEqualToString:@"navLabel"]) {
|
|
_inNavLabel = YES;
|
|
} else if ([elementName isEqualToString:@"text"]) {
|
|
_inText = YES;
|
|
} else if ([elementName isEqualToString:@"content"]) {
|
|
_inContent = YES;
|
|
_currentSrc = attributeDict[@"src"];
|
|
}
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
foundCharacters:(NSString *)string
|
|
{
|
|
[_currentText appendString:string];
|
|
}
|
|
|
|
- (void)parser:(NSXMLParser *)parser
|
|
didEndElement:(NSString *)elementName
|
|
namespaceURI:(NSString *)namespaceURI
|
|
qualifiedName:(NSString *)qName
|
|
{
|
|
if ([elementName isEqualToString:@"text"] && _inText) {
|
|
_currentLabel = [_currentText stringByTrimmingCharactersInSet:
|
|
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
_inText = NO;
|
|
} else if ([elementName isEqualToString:@"content"]) {
|
|
_inContent = NO;
|
|
} else if ([elementName isEqualToString:@"navLabel"]) {
|
|
_inNavLabel = NO;
|
|
} else if ([elementName isEqualToString:@"navPoint"]) {
|
|
_inNavPoint = NO;
|
|
|
|
if (_currentLabel && _currentSrc) {
|
|
NSDictionary *entry = @{
|
|
@"id" : _currentNavPointId ?: @"",
|
|
@"label" : _currentLabel,
|
|
@"src" : _currentSrc,
|
|
@"playOrder" : @(_playOrder)
|
|
};
|
|
[_tocEntries addObject:entry];
|
|
}
|
|
|
|
_currentNavPointId = nil;
|
|
_currentLabel = nil;
|
|
_currentSrc = nil;
|
|
}
|
|
|
|
_currentText = nil;
|
|
}
|
|
|
|
@end
|