// // 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 // ============================================================================ #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 *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 *)rangeValueWithPageInfo:(NSDictionary *)pageInfo { NSMutableArray *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 *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 *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