443 lines
18 KiB
Objective-C
443 lines
18 KiB
Objective-C
//
|
||
// 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
|