// // WRChapterData.m // WeRead (读书) // // Detailed pseudo-code reconstruction of the chapter data model. // Stores the typeset NSAttributedString, manages highlights, underlines, // reviews, page ranges, and outline generation. // #import "WRChapterData.h" #import "WRCoreTextLayouter.h" // ============================================================================ #pragma mark - Constants // ============================================================================ static NSString *const kHighlightRangeKey = @"range"; static NSString *const kHighlightKeyKey = @"key"; static NSString *const kHighlightItemIdKey = @"itemId"; static NSString *const kHighlightColorKey = @"color"; static NSString *const kUnderlineStyleKey = @"style"; static NSString *const kUnderlineTypeKey = @"type"; // Custom attribute name used in the attributed string to mark underlines. static NSString *const kWRUnderlineAttributeName = @"com.weread.underline"; // Custom attribute name for highlight color. static NSString *const kWRHighlightAttributeName = @"com.weread.highlight"; // ============================================================================ #pragma mark - WRChapterData () // ============================================================================ @interface WRChapterData () /// Internal mutable copy of highlights for mutation. @property (nonatomic, strong) NSMutableArray *mutableHighlights; /// Internal mutable copy of underlines. @property (nonatomic, strong) NSMutableArray *mutableUnderlines; /// Internal mutable copy of temp review highlights. @property (nonatomic, strong) NSMutableArray *mutableTempReviewHighlights; @end // ============================================================================ #pragma mark - WRChapterData Implementation // ============================================================================ @implementation WRChapterData // ============================================================================ #pragma mark - Initialization // ============================================================================ - (instancetype)init { self = [super init]; if (self) { _mutableHighlights = [NSMutableArray array]; _mutableUnderlines = [NSMutableArray array]; _mutableTempReviewHighlights = [NSMutableArray array]; _bookmarkedPages = [NSSet set]; _freeTrialCutOffLocation = NSNotFound; _contentInsets = UIEdgeInsetsMake(20.0, 16.0, 20.0, 16.0); } return self; } // ============================================================================ #pragma mark - Class Methods // ============================================================================ /// /// Adds an underline decoration to an NSMutableAttributedString at the /// specified range. The underline is stored as a custom attribute so the /// drawing code can render it with the correct style and color. /// /// @param attributedString The mutable attributed string to modify. /// @param range The character range to underline. /// @param itemId Identifier for the item (e.g., bookmark or review ID). /// @param style The underline style (solid, dashed, wavy). /// @param color The underline color. /// + (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attributedString range:(NSRange)range itemId:(NSString *)itemId style:(WRUnderlineStyle)style color:(UIColor *)color { if (!attributedString || range.location == NSNotFound) return; if (NSMaxRange(range) > attributedString.length) return; // Build the underline descriptor dictionary. NSDictionary *underlineInfo = @{ kHighlightItemIdKey : itemId ?: @"", kUnderlineStyleKey : @(style), kHighlightColorKey : color ?: [UIColor blackColor], }; // Apply as a custom attribute. The rendering code in WRCoreTextLayoutFrame // will read this attribute and draw the underline during -drawInContext:. [attributedString addAttribute:kWRUnderlineAttributeName value:underlineInfo range:range]; } /// /// Calculates the free trial cutoff location within the attributed string. /// The book object provides trial chapter limits; this method finds the /// corresponding character position in the typeset string. /// /// @param attributedString The typeset attributed string. /// @param book The book model object (provides trial info). /// @return The character index at which to cut off, or NSNotFound if fully accessible. /// + (NSUInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:(NSAttributedString *)attributedString book:(id)book { if (!attributedString || !book) return NSNotFound; // In the real implementation, this queries the book model for: // - The number of free trial characters / chapters allowed. // - Whether this specific chapter falls within the trial range. // It then maps that to a character index in the attributed string. // // Typical logic: // 1. Ask `book` for the trial character count or chapter index limit. // 2. If this chapter is entirely within the trial, return NSNotFound (no cutoff). // 3. If this chapter is entirely beyond the trial, return 0 (show nothing). // 4. If the cutoff falls within this chapter, calculate the offset. // Placeholder: assume the book responds to -freeTrialCharacterLimit. if ([book respondsToSelector:NSSelectorFromString(@"freeTrialCharacterLimit")]) { NSUInteger limit = [[book valueForKey:@"freeTrialCharacterLimit"] unsignedIntegerValue]; NSUInteger totalLength = attributedString.length; if (limit >= totalLength) { // Entire chapter is accessible. return NSNotFound; } else if (limit == 0) { // No access. return 0; } else { return limit; } } return NSNotFound; } // ============================================================================ #pragma mark - Highlight & Underline Management // ============================================================================ /// Adds an auto-read underline. This is a visual indicator showing which /// text is being auto-scrolled through. - (void)addAutoReadUnderLineInRange:(NSRange)range style:(WRUnderlineStyle)style color:(UIColor *)color { if (range.location == NSNotFound) return; // Apply the underline attribute to the typeset string. [WRChapterData addUnderLineToAttributedString:self.typesetAttributedString range:range itemId:@"autoRead" style:style color:color]; } /// Adds a persistent highlight annotation. - (void)addHighlightInRange:(NSRange)range key:(NSString *)key itemId:(NSString *)itemId color:(UIColor *)color { if (range.location == NSNotFound) return; NSDictionary *entry = @{ kHighlightRangeKey : [NSValue valueWithRange:range], kHighlightKeyKey : key ?: @"", kHighlightItemIdKey : itemId ?: @"", kHighlightColorKey : color ?: [UIColor yellowColor], }; [_mutableHighlights addObject:entry]; _highlights = [_mutableHighlights copy]; // Also apply the highlight as a custom attribute on the attributed string // so the CoreText drawing code can render the background color. [self.typesetAttributedString addAttribute:kWRHighlightAttributeName value:@{kHighlightColorKey: (color ?: [UIColor yellowColor])} range:range]; } /// Adds a review underline (from a friend's annotation or review). - (void)addReviewUnderlineInRange:(NSRange)range itemId:(NSString *)itemId type:(WRReviewType)type { if (range.location == NSNotFound) return; NSDictionary *entry = @{ kHighlightRangeKey : [NSValue valueWithRange:range], kHighlightItemIdKey : itemId ?: @"", kUnderlineTypeKey : @(type), }; [_mutableUnderlines addObject:entry]; _underlines = [_mutableUnderlines copy]; // Apply underline attribute for rendering. WRUnderlineStyle style = (type == WRReviewTypeHighlight) ? WRUnderlineStyleNone : WRUnderlineStyleSolid; UIColor *color = (type == WRReviewTypeHighlight) ? [UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:0.3] : [UIColor colorWithRed:1.0 green:0.4 blue:0.4 alpha:0.8]; if (style != WRUnderlineStyleNone) { [WRChapterData addUnderLineToAttributedString:self.typesetAttributedString range:range itemId:itemId style:style color:color]; } } /// Adds a temporary review highlight (used during the review creation flow /// before the user confirms and persists it). - (void)addTempReviewHighlightInRange:(NSRange)range itemId:(NSString *)itemId { [self addTempReviewHighlightInRange:range itemId:itemId color:[UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:0.3]]; } /// Adds a temporary review highlight with a custom color. - (void)addTempReviewHighlightInRange:(NSRange)range itemId:(NSString *)itemId color:(UIColor *)color { if (range.location == NSNotFound) return; NSDictionary *entry = @{ kHighlightRangeKey : [NSValue valueWithRange:range], kHighlightItemIdKey : itemId ?: @"", kHighlightColorKey : color ?: [UIColor yellowColor], }; [_mutableTempReviewHighlights addObject:entry]; _tempReviewHighlights = [_mutableTempReviewHighlights copy]; // Apply as a temporary attribute (not persisted). [self.typesetAttributedString addAttribute:kWRHighlightAttributeName value:@{kHighlightColorKey: (color ?: [UIColor yellowColor]), @"temporary": @YES} range:range]; } /// Removes a review underline from both the internal array and the /// attributed string attributes. - (void)deleteReviewUnderlineInRange:(NSRange)range type:(WRReviewType)type { if (range.location == NSNotFound) return; // Remove matching entries from the mutable array. NSMutableArray *toRemove = [NSMutableArray array]; for (NSDictionary *entry in _mutableUnderlines) { NSRange entryRange = [entry[kHighlightRangeKey] rangeValue]; WRReviewType entryType = [entry[kUnderlineTypeKey] integerValue]; if (NSEqualRanges(entryRange, range) && entryType == type) { [toRemove addObject:entry]; } } [_mutableUnderlines removeObjectsInArray:toRemove]; _underlines = [_mutableUnderlines copy]; // Remove the custom underline/highlight attributes from the string. [self.typesetAttributedString removeAttribute:kWRUnderlineAttributeName range:range]; [self.typesetAttributedString removeAttribute:kWRHighlightAttributeName range:range]; } // ============================================================================ #pragma mark - Page Range Queries // ============================================================================ /// Returns the character range for the given page index. /// The pageRanges array is computed during layout by WRCoreTextLayouter /// and stored as an array of NSValue-wrapped NSRange objects. - (NSRange)rangeOfPage:(NSUInteger)pageIndex { if (pageIndex >= self.pageRanges.count) { return NSMakeRange(NSNotFound, 0); } return [self.pageRanges[pageIndex] rangeValue]; } // ============================================================================ #pragma mark - Outline Generation // ============================================================================ /// Scans the typeset attributed string for heading-level paragraph styles /// and builds an array of outline entries. Each entry is a dictionary with: /// @"title" : NSString (the heading text) /// @"level" : NSNumber (1 for H1, 2 for H2, etc.) /// @"range" : NSValue wrapping the NSRange in the attributed string. - (void)generateOutlineContents { NSMutableArray *outline = [NSMutableArray array]; NSAttributedString *str = self.typesetAttributedString; if (!str || str.length == 0) { self.outlineContents = @[]; return; } // Walk the attributed string by paragraph. NSString *plainText = [str string]; NSUInteger length = plainText.length; NSUInteger searchLoc = 0; while (searchLoc < length) { // Find the paragraph range. NSRange paraRange = [plainText rangeOfString:@"\n" options:0 range:NSMakeRange(searchLoc, length - searchLoc)]; if (paraRange.location == NSNotFound) { paraRange = NSMakeRange(searchLoc, length - searchLoc); } else { paraRange = NSMakeRange(searchLoc, paraRange.location - searchLoc + 1); } // Check if this paragraph has a heading font attribute. if (paraRange.length > 0) { NSDictionary *attrs = [str attributesAtIndex:paraRange.location effectiveRange:NULL]; UIFont *font = attrs[NSFontAttributeName]; // Heuristic: heading fonts are typically larger than body text. // In WeRead, headings may use a custom attribute or a specific // font descriptor. We check for font size > bodySize + 4. CGFloat bodySize = 16.0; // Typical body font size. if (font && font.pointSize > bodySize + 4.0) { NSString *title = [plainText substringWithRange: NSMakeRange(paraRange.location, paraRange.length > 1 ? paraRange.length - 1 : paraRange.length)]; title = [title stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]]; if (title.length > 0) { NSInteger level = 1; if (font.pointSize < bodySize + 8.0) level = 2; if (font.pointSize < bodySize + 6.0) level = 3; [outline addObject:@{ @"title": title, @"level": @(level), @"range": [NSValue valueWithRange:paraRange], }]; } } } searchLoc = NSMaxRange(paraRange); if (searchLoc >= length) break; } self.outlineContents = [outline copy]; } // ============================================================================ #pragma mark - Free Trial Cutoff // ============================================================================ /// Returns the real string location accounting for typesetting differences. /// The raw cutoff location from the server may differ from the position in /// the typeset attributed string due to inserted image attachments, etc. - (NSUInteger)freeTrialChapterCutOffRealStringLocation { if (self.freeTrialCutOffLocation == NSNotFound) { return NSNotFound; } // In the real implementation, this maps from the source string index // to the typeset string index, accounting for: // - Image attachment characters () inserted during typesetting. // - Font substitution changes in string length (rare). // // Simple approach: walk both strings in parallel, counting the offset. NSUInteger sourceLoc = self.freeTrialCutOffLocation; NSAttributedString *source = self.sourceAttributedString; NSMutableAttributedString *typeset = self.typesetAttributedString; if (!source || !typeset) return sourceLoc; // If the source and typeset strings have the same length, no mapping needed. if (source.length == typeset.length) return sourceLoc; // Otherwise, use a character-by-character mapping. // This is a simplified version; the real code may use a precomputed map. NSUInteger typesetLoc = 0; NSUInteger sourceIdx = 0; NSString *sourcePlain = [source string]; NSString *typesetPlain = [typeset string]; while (sourceIdx < sourceLoc && typesetLoc < typesetPlain.length) { // Skip image attachment characters in the typeset string. unichar tc = [typesetPlain characterAtIndex:typesetLoc]; if (tc == 0xFFFC) { // NSAttachmentCharacter typesetLoc++; continue; } sourceIdx++; typesetLoc++; } return typesetLoc; } /// Sets the free trial cutoff location from the source string index. - (void)markFreeTrialChapterCutOffStringLocation:(NSUInteger)location { self.freeTrialCutOffLocation = location; } // ============================================================================ #pragma mark - Property Accessors (Ivar-backed from binary) // ============================================================================ // The binary shows these ivar types: // NSMutableAttributedString -> _typesetAttributedString // NSArray -> _pageRanges // NSArray -> _highlights // NSArray -> _underlines // NSArray -> _tempReviewHighlights // NSArray -> _outlineContents // NSArray -> _imageAttachments (used by layout for inline images) // WRCoreTextLayouter -> _layouter // NSSet -> _bookmarkedPages // NSAttributedString -> _sourceAttributedString // These are standard @property synthesized accessors; the binary confirms // the backing ivar names and types match. @end