309 lines
11 KiB
Objective-C
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
|