// // 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 *chapters; @property (nonatomic, strong, readwrite) NSDictionary *resourceMap; @property (nonatomic, strong, readwrite) NSArray *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 *_manifestMap; // id -> href NSMutableDictionary *_mediaTypeMap; // id -> media-type NSMutableArray *_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: // // // // // 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: // -> populate _manifestMap and _mediaTypeMap // -> populate _spineRefs (ordered idref list) // -> 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: // // // // Chapter 1 // // ... nested ... // // // 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 @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 *)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 @property (nonatomic, strong) NSMutableDictionary *manifestItems; @property (nonatomic, strong) NSMutableDictionary *mediaTypes; @property (nonatomic, strong) NSMutableArray *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 *)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 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 @property (nonatomic, strong) NSMutableArray *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 *)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