Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user