ReadViewSDK/Doc/WXRead/decompiled/WRChapterData.m

443 lines
18 KiB
Objective-C
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<NSDictionary *> *mutableHighlights;
/// Internal mutable copy of underlines.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableUnderlines;
/// Internal mutable copy of temp review highlights.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *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<NSDictionary *> *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