ReadViewSDK/Doc/WXRead/decompiled/WREpubParser.m
2026-05-21 19:40:51 +08:00

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