ReadViewSDK/Doc/WXRead/decompiled/WRChapterPageCount.m

309 lines
11 KiB
Objective-C

//
// WRChapterPageCount.m
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the pagination calculator.
// Computes page breaks by simulating CoreText line layout and fitting
// lines into the available vertical space.
//
#import "WRChapterPageCount.h"
#import <CoreText/CoreText.h>
// ============================================================================
#pragma mark - Constants
// ============================================================================
/// Prefix for pagination cache keys.
static NSString *const kPageCountCachePrefix = @"weread_pagcount";
/// Separator used in cache key components.
static NSString *const kCacheKeySeparator = @"_";
// ============================================================================
#pragma mark - WRChapterPageCount ()
// ============================================================================
@interface WRChapterPageCount ()
/// Precomputed page ranges (cached after calculation).
@property (nonatomic, strong) NSMutableArray<NSValue *> *mutablePageRanges;
@end
// ============================================================================
#pragma mark - WRChapterPageCount Implementation
// ============================================================================
@implementation WRChapterPageCount
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)init {
self = [super init];
if (self) {
_mutablePageRanges = [NSMutableArray array];
}
return self;
}
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
///
/// Generates a cache key that encodes the book ID together with the current
/// typesetter configuration (font size, line spacing, margins, page size).
/// When any of these change, the cache key changes, invalidating old data.
///
/// Typical format:
/// weread_pagcount_{bookId}_{fontSize}_{lineSpacing}_{pageWidth}_{pageHeight}
///
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId {
if (!bookId) return kPageCountCachePrefix;
// Read current typesetter settings from NSUserDefaults or a global config.
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
CGFloat fontSize = [defaults floatForKey:@"WRTypesetterFontSize"] ?: 18.0;
CGFloat lineSpacing = [defaults floatForKey:@"WRTypesetterLineSpacing"] ?: 1.5;
CGFloat pageWidth = [defaults floatForKey:@"WRTypesetterPageWidth"] ?: 375.0;
CGFloat pageHeight = [defaults floatForKey:@"WRTypesetterPageHeight"] ?: 667.0;
// Build the key. Use integer representations to avoid locale issues.
NSString *key = [NSString stringWithFormat:@"%@%@%@%@%.0f%@%.1f%@%.0f%@%.0f",
kPageCountCachePrefix,
kCacheKeySeparator,
bookId,
kCacheKeySeparator,
fontSize * 10, // e.g., 180 for 18.0pt
kCacheKeySeparator,
lineSpacing * 10, // e.g., 15 for 1.5
kCacheKeySeparator,
pageWidth,
kCacheKeySeparator,
pageHeight];
return key;
}
///
/// Converts a page info dictionary (from server or local computation)
/// into an array of NSValue-wrapped NSRange objects.
///
/// Expected pageInfo format:
/// {
/// @"ranges": @[
/// @{@"location": @0, @"length": @500},
/// @{@"location": @500, @"length": @480},
/// ...
/// ]
/// }
///
/// Or alternatively, an array of two-element arrays:
/// {
/// @"ranges": @[@[@0, @500], @[@500, @480], ...]
/// }
///
+ (NSArray<NSValue *> *)rangeValueWithPageInfo:(NSDictionary *)pageInfo {
NSMutableArray<NSValue *> *result = [NSMutableArray array];
if (!pageInfo) return [result copy];
NSArray *ranges = pageInfo[@"ranges"];
if (!ranges || ![ranges isKindOfClass:[NSArray class]]) return [result copy];
for (id entry in ranges) {
NSRange range = NSMakeRange(NSNotFound, 0);
if ([entry isKindOfClass:[NSDictionary class]]) {
// Dictionary format: {location, length}
NSDictionary *dict = (NSDictionary *)entry;
NSUInteger loc = [dict[@"location"] unsignedIntegerValue];
NSUInteger len = [dict[@"length"] unsignedIntegerValue];
range = NSMakeRange(loc, len);
} else if ([entry isKindOfClass:[NSArray class]]) {
// Array format: [location, length]
NSArray *arr = (NSArray *)entry;
if (arr.count >= 2) {
NSUInteger loc = [arr[0] unsignedIntegerValue];
NSUInteger len = [arr[1] unsignedIntegerValue];
range = NSMakeRange(loc, len);
}
} else if ([entry isKindOfClass:[NSValue class]]) {
// Already an NSValue wrapping NSRange.
range = [(NSValue *)entry rangeValue];
}
if (range.location != NSNotFound) {
[result addObject:[NSValue valueWithRange:range]];
}
}
return [result copy];
}
// ============================================================================
#pragma mark - Instance Methods
// ============================================================================
/// Returns the character range for the page at the given index.
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex {
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
if (pageIndex >= ranges.count) {
return NSMakeRange(NSNotFound, 0);
}
return [ranges[pageIndex] rangeValue];
}
/// Binary-search-style lookup: finds the page index containing the given
/// character index. Pages are contiguous, so we can use binary search.
- (NSUInteger)pageIndexForCharacterIndex:(NSUInteger)charIndex {
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
if (ranges.count == 0) return 0;
NSUInteger lo = 0;
NSUInteger hi = ranges.count - 1;
while (lo <= hi) {
NSUInteger mid = lo + (hi - lo) / 2;
NSRange midRange = [ranges[mid] rangeValue];
if (charIndex >= midRange.location &&
charIndex < NSMaxRange(midRange)) {
return mid;
} else if (charIndex < midRange.location) {
if (mid == 0) break;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
// If not found, return the last page (clamp).
return ranges.count - 1;
}
///
/// Recalculates page ranges by simulating CoreText typesetting.
/// This is the core pagination algorithm:
///
/// 1. Create a CTFramesetter from the attributed string.
/// 2. For each page, create a CTFrame with the available height.
/// 3. Count how many lines fit in the frame.
/// 4. Sum the character counts of those lines to get the page range.
/// 5. Advance the start position and repeat.
///
- (void)recalculatePageRangesForAttributedString:(NSAttributedString *)attributedString
drawingSize:(CGSize)drawingSize
margins:(UIEdgeInsets)margins {
[self.mutablePageRanges removeAllObjects];
if (!attributedString || attributedString.length == 0) {
self.pageRanges = @[];
return;
}
// ---- Step 1: Create the CTFramesetter ----
CTFramesetterRef framesetter =
CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attributedString);
if (!framesetter) {
self.pageRanges = @[];
return;
}
// ---- Step 2: Compute the usable drawing area ----
CGFloat usableWidth = drawingSize.width - margins.left - margins.right;
CGFloat usableHeight = drawingSize.height - margins.top - margins.bottom;
if (usableWidth <= 0 || usableHeight <= 0) {
CFRelease(framesetter);
self.pageRanges = @[];
return;
}
// ---- Step 3: Paginate ----
NSUInteger totalLength = attributedString.length;
NSUInteger currentLocation = 0;
while (currentLocation < totalLength) {
// Create a path for this page's drawing area.
CGRect pathRect = CGRectMake(margins.left, margins.bottom,
usableWidth, usableHeight);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, pathRect);
// Create a frame for the remaining text.
// The frame will typeset as much text as fits in the path.
CFRange frameRange = CFRangeMake((CFIndex)currentLocation, 0); // 0 = until end
CTFrameRef frame = CTFramesetterCreateFrame(framesetter,
frameRange,
path, NULL);
CGPathRelease(path);
if (!frame) break;
// Get the lines that fit in this page.
NSArray *lines = (__bridge_transfer NSArray *)CTFrameGetLines(frame);
if (lines.count == 0) {
CFRelease(frame);
break;
}
// Get the line origins to determine which lines are fully within bounds.
CGPoint *origins = (CGPoint *)calloc(lines.count, sizeof(CGPoint));
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
NSUInteger pageCharCount = 0;
for (NSUInteger i = 0; i < lines.count; i++) {
CTLineRef line = (__bridge CTLineRef)lines[i];
CFRange lineRange = CTLineGetStringRange(line);
// Check if this line's origin is within the usable area.
// CoreText origins are from the bottom of the frame.
CGFloat lineY = origins[i].y;
CGFloat lineHeight = 0.0;
CGFloat descent = 0.0;
CTLineGetTypographicBounds(line, &lineHeight, &descent, NULL);
// If the line's top is above the top of the frame, it doesn't fit.
if (lineY - lineHeight > usableHeight) {
break; // This line doesn't fit; stop here.
}
pageCharCount += (NSUInteger)lineRange.length;
}
free(origins);
CFRelease(frame);
// If no characters fit (shouldn't happen normally), advance by 1 to avoid infinite loop.
if (pageCharCount == 0) {
pageCharCount = 1;
}
// Record this page's range.
NSRange pageRange = NSMakeRange(currentLocation, pageCharCount);
[self.mutablePageRanges addObject:[NSValue valueWithRange:pageRange]];
currentLocation += pageCharCount;
}
CFRelease(framesetter);
self.pageRanges = [self.mutablePageRanges copy];
}
// ============================================================================
#pragma mark - Computed Properties
// ============================================================================
- (NSUInteger)totalPages {
return self.pageRanges.count;
}
@end