Coverage for pyWebLayout/layout/ereader_layout.py: 83%

279 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +0000

1""" 

2Enhanced ereader layout system with position tracking, font scaling, and multi-page support. 

3 

4This module provides the core infrastructure for building high-performance ereader applications 

5with features like: 

6- Precise position tracking tied to abstract document structure 

7- Font scaling support 

8- Bidirectional page rendering (forward/backward) 

9- Chapter navigation based on HTML headings 

10- Multi-process page buffering 

11- Sub-second page rendering performance 

12""" 

13 

14from __future__ import annotations 

15from dataclasses import dataclass, asdict 

16from typing import List, Dict, Tuple, Optional, Any 

17 

18from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList, Image 

19from pyWebLayout.abstract.inline import Word 

20from pyWebLayout.concrete.page import Page 

21from pyWebLayout.concrete.text import Text 

22from pyWebLayout.style.page_style import PageStyle 

23from pyWebLayout.style import Font 

24from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle 

25from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter 

26 

27 

28@dataclass 

29class RenderingPosition: 

30 """ 

31 Complete state for resuming rendering at any point in a document. 

32 Position is tied to abstract document structure for stability across font changes. 

33 """ 

34 chapter_index: int = 0 # Which chapter (based on headings) 

35 block_index: int = 0 # Which block within chapter 

36 # Which word within block (for paragraphs) 

37 word_index: int = 0 

38 table_row: int = 0 # Which row for tables 

39 table_col: int = 0 # Which column for tables 

40 list_item_index: int = 0 # Which item for lists 

41 remaining_pretext: Optional[str] = None # Hyphenated word continuation 

42 page_y_offset: int = 0 # Vertical position on page 

43 

44 def to_dict(self) -> Dict[str, Any]: 

45 """Serialize position for saving to file/database""" 

46 return asdict(self) 

47 

48 @classmethod 

49 def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition': 

50 """Deserialize position from saved state""" 

51 return cls(**data) 

52 

53 def copy(self) -> 'RenderingPosition': 

54 """Create a copy of this position""" 

55 return RenderingPosition(**asdict(self)) 

56 

57 def __eq__(self, other) -> bool: 

58 """Check if two positions are equal""" 

59 if not isinstance(other, RenderingPosition): 

60 return False 

61 return asdict(self) == asdict(other) 

62 

63 def __hash__(self) -> int: 

64 """Make position hashable for use as dict key""" 

65 return hash(tuple(asdict(self).values())) 

66 

67 

68class ChapterInfo: 

69 """Information about a chapter/section in the document""" 

70 

71 def __init__( 

72 self, 

73 title: str, 

74 level: HeadingLevel, 

75 position: RenderingPosition, 

76 block_index: int): 

77 self.title = title 

78 self.level = level 

79 self.position = position 

80 self.block_index = block_index 

81 

82 

83class ChapterNavigator: 

84 """ 

85 Handles chapter/section navigation based on HTML heading structure (H1-H6). 

86 Builds a table of contents and provides navigation capabilities. 

87 """ 

88 

89 def __init__(self, blocks: List[Block]): 

90 self.blocks = blocks 

91 self.chapters: List[ChapterInfo] = [] 

92 self._build_chapter_map() 

93 

94 def _build_chapter_map(self): 

95 """Scan blocks for headings and build chapter navigation map""" 

96 current_chapter_index = 0 

97 

98 # Check if first block is a cover image and add it to TOC 

99 if self.blocks and isinstance(self.blocks[0], Image): 

100 cover_position = RenderingPosition( 

101 chapter_index=0, 

102 block_index=0, 

103 word_index=0, 

104 table_row=0, 

105 table_col=0, 

106 list_item_index=0 

107 ) 

108 

109 cover_info = ChapterInfo( 

110 title="Cover", 

111 level=HeadingLevel.H1, # Treat as top-level entry 

112 position=cover_position, 

113 block_index=0 

114 ) 

115 

116 self.chapters.append(cover_info) 

117 

118 for block_index, block in enumerate(self.blocks): 

119 if isinstance(block, Heading): 

120 # Create position for this heading 

121 position = RenderingPosition( 

122 chapter_index=current_chapter_index, 

123 block_index=block_index, # Use actual block index 

124 word_index=0, 

125 table_row=0, 

126 table_col=0, 

127 list_item_index=0 

128 ) 

129 

130 # Extract heading text 

131 heading_text = self._extract_heading_text(block) 

132 

133 chapter_info = ChapterInfo( 

134 title=heading_text, 

135 level=block.level, 

136 position=position, 

137 block_index=block_index 

138 ) 

139 

140 self.chapters.append(chapter_info) 

141 

142 # Only increment chapter index for top-level headings (H1) 

143 if block.level == HeadingLevel.H1: 

144 current_chapter_index += 1 

145 

146 def _extract_heading_text(self, heading: Heading) -> str: 

147 """Extract text content from a heading block""" 

148 words = [] 

149 for position, word in heading.words_iter(): 

150 if isinstance(word, Word): 150 ↛ 149line 150 didn't jump to line 149 because the condition on line 150 was always true

151 words.append(word.text) 

152 return " ".join(words) 

153 

154 def get_table_of_contents( 

155 self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]: 

156 """Generate table of contents from heading structure""" 

157 return [(chapter.title, chapter.level, chapter.position) 

158 for chapter in self.chapters] 

159 

160 def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]: 

161 """Get rendering position for a chapter by title""" 

162 for chapter in self.chapters: 

163 if chapter.title.lower() == chapter_title.lower(): 

164 return chapter.position 

165 return None 

166 

167 def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]: 

168 """Determine which chapter contains the current position""" 

169 if not self.chapters: 

170 return None 

171 

172 # Find the chapter that contains this position 

173 for i, chapter in enumerate(self.chapters): 173 ↛ 182line 173 didn't jump to line 182 because the loop on line 173 didn't complete

174 # Check if this is the last chapter or if position is before next chapter 

175 if i == len(self.chapters) - 1: 

176 return chapter 

177 

178 next_chapter = self.chapters[i + 1] 

179 if position.chapter_index < next_chapter.position.chapter_index: 

180 return chapter 

181 

182 return self.chapters[0] if self.chapters else None 

183 

184 

185class FontFamilyOverride: 

186 """ 

187 Manages font family preferences for ereader rendering. 

188 Allows dynamic font family switching without modifying source blocks. 

189 """ 

190 

191 def __init__(self, preferred_family: Optional[BundledFont] = None): 

192 """ 

193 Initialize font family override. 

194 

195 Args: 

196 preferred_family: Preferred bundled font family (None = use original fonts) 

197 """ 

198 self.preferred_family = preferred_family 

199 

200 def override_font(self, font: Font) -> Font: 

201 """ 

202 Create a new font with the preferred family while preserving other attributes. 

203 

204 Args: 

205 font: Original font object 

206 

207 Returns: 

208 Font with overridden family, or original if no override is set 

209 """ 

210 if self.preferred_family is None: 

211 return font 

212 

213 # Get the appropriate font path for the preferred family 

214 # preserving the original font's weight and style 

215 new_font_path = get_bundled_font_path( 

216 family=self.preferred_family, 

217 weight=font.weight, 

218 style=font.style 

219 ) 

220 

221 # If we couldn't find a matching font, fall back to original 

222 if new_font_path is None: 

223 return font 

224 

225 # Create a new font with the overridden path 

226 return Font( 

227 font_path=new_font_path, 

228 font_size=font.font_size, 

229 colour=font.colour, 

230 weight=font.weight, 

231 style=font.style, 

232 decoration=font.decoration, 

233 background=font.background, 

234 language=font.language, 

235 min_hyphenation_width=font.min_hyphenation_width 

236 ) 

237 

238 

239class FontScaler: 

240 """ 

241 Handles font scaling operations for ereader font size adjustments. 

242 Applies scaling at layout/render time while preserving original font objects. 

243 """ 

244 

245 @staticmethod 

246 def scale_font(font: Font, scale_factor: float, family_override: Optional[FontFamilyOverride] = None) -> Font: 

247 """ 

248 Create a scaled version of a font for layout calculations. 

249 

250 Args: 

251 font: Original font object 

252 scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.) 

253 family_override: Optional font family override 

254 

255 Returns: 

256 New Font object with scaled size and optional family override 

257 """ 

258 # Apply family override first if specified 

259 working_font = font 

260 if family_override is not None: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 working_font = family_override.override_font(font) 

262 

263 # Then apply scaling 

264 if scale_factor == 1.0: 

265 return working_font 

266 

267 scaled_size = max(1, int(working_font.font_size * scale_factor)) 

268 

269 return Font( 

270 font_path=working_font._font_path, 

271 font_size=scaled_size, 

272 colour=working_font.colour, 

273 weight=working_font.weight, 

274 style=working_font.style, 

275 decoration=working_font.decoration, 

276 background=working_font.background, 

277 language=working_font.language, 

278 min_hyphenation_width=working_font.min_hyphenation_width 

279 ) 

280 

281 @staticmethod 

282 def scale_word_spacing(spacing: Tuple[int, int], 

283 scale_factor: float) -> Tuple[int, int]: 

284 """Scale word spacing constraints proportionally""" 

285 if scale_factor == 1.0: 

286 return spacing 

287 

288 min_spacing, max_spacing = spacing 

289 return ( 

290 max(1, int(min_spacing * scale_factor)), 

291 max(2, int(max_spacing * scale_factor)) 

292 ) 

293 

294 

295class BidirectionalLayouter: 

296 """ 

297 Core layout engine supporting both forward and backward page rendering. 

298 Handles font scaling and maintains position state. 

299 """ 

300 

301 def __init__(self, 

302 blocks: List[Block], 

303 page_style: PageStyle, 

304 page_size: Tuple[int, 

305 int] = (800, 

306 600), 

307 alignment_override=None, 

308 font_family_override: Optional[FontFamilyOverride] = None): 

309 self.blocks = blocks 

310 self.page_style = page_style 

311 self.page_size = page_size 

312 self.chapter_navigator = ChapterNavigator(blocks) 

313 self.alignment_override = alignment_override 

314 self.font_family_override = font_family_override 

315 

316 def render_page_forward(self, position: RenderingPosition, 

317 font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: 

318 """ 

319 Render a page starting from the given position, moving forward through the document. 

320 

321 Args: 

322 position: Starting position in document 

323 font_scale: Font scaling factor 

324 

325 Returns: 

326 Tuple of (rendered_page, next_position) 

327 """ 

328 page = Page(size=self.page_size, style=self.page_style) 

329 current_pos = position.copy() 

330 

331 # Start laying out blocks from the current position 

332 while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0: 

333 # Additional bounds check to prevent IndexError 

334 if current_pos.block_index >= len(self.blocks): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 break 

336 

337 block = self.blocks[current_pos.block_index] 

338 

339 # Apply font scaling to the block 

340 scaled_block = self._scale_block_fonts(block, font_scale) 

341 

342 # Try to fit the block on the current page 

343 success, new_pos = self._layout_block_on_page( 

344 scaled_block, page, current_pos, font_scale) 

345 

346 if not success: 

347 # Block doesn't fit, we're done with this page 

348 break 

349 

350 # Add inter-block spacing after successfully laying out a block 

351 # Only add if we're not at the end of the document and there's space 

352 if new_pos.block_index < len(self.blocks): 

353 page._current_y_offset += self.page_style.inter_block_spacing 

354 

355 # Ensure new position doesn't go beyond bounds 

356 if new_pos.block_index >= len(self.blocks): 

357 # We've reached the end of the document 

358 current_pos = new_pos 

359 break 

360 

361 current_pos = new_pos 

362 

363 return page, current_pos 

364 

365 def render_page_backward(self, 

366 end_position: RenderingPosition, 

367 font_scale: float = 1.0) -> Tuple[Page, 

368 RenderingPosition]: 

369 """ 

370 Render a page that ends at the given position, filling backward. 

371 Critical for "previous page" navigation. 

372 

373 Uses iterative refinement to find the correct start position that 

374 results in a page ending at (or very close to) the target position. 

375 

376 Args: 

377 end_position: Position where page should end 

378 font_scale: Font scaling factor 

379 

380 Returns: 

381 Tuple of (rendered_page, start_position) 

382 """ 

383 # Handle edge case: already at beginning 

384 if end_position.block_index == 0 and end_position.word_index == 0: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true

385 return self.render_page_forward(end_position, font_scale) 

386 

387 # Start with initial estimate 

388 estimated_start = self._estimate_page_start(end_position, font_scale) 

389 

390 # Iterative refinement: keep adjusting until we converge or hit max iterations 

391 max_iterations = 10 

392 best_page = None 

393 best_start = estimated_start 

394 best_distance = float('inf') 

395 

396 for iteration in range(max_iterations): 

397 # Render forward from current estimate 

398 page, actual_end = self.render_page_forward(estimated_start, font_scale) 

399 

400 # Calculate how far we are from target 

401 comparison = self._position_compare(actual_end, end_position) 

402 

403 # Perfect match or close enough (within same block) 

404 # BUT: ensure we actually moved backward (estimated_start < end_position) 

405 if comparison == 0: 

406 # Check if we actually found a valid previous page 

407 if self._position_compare(estimated_start, end_position) < 0: 407 ↛ 411line 407 didn't jump to line 411 because the condition on line 407 was always true

408 return page, estimated_start 

409 # If estimated_start >= end_position, we haven't moved backward 

410 # Continue iterating to find a better position 

411 elif iteration == 0: 

412 # On first iteration, if we can't find a previous position, 

413 # we're likely at or near the beginning 

414 break 

415 

416 # Track best result so far 

417 distance = abs(actual_end.block_index - end_position.block_index) 

418 if distance < best_distance: 

419 best_distance = distance 

420 best_page = page 

421 best_start = estimated_start.copy() 

422 

423 # Adjust estimate for next iteration 

424 estimated_start = self._adjust_start_estimate( 

425 estimated_start, end_position, actual_end) 

426 

427 # Safety: don't go before document start 

428 if estimated_start.block_index < 0: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true

429 estimated_start.block_index = 0 

430 estimated_start.word_index = 0 

431 

432 # If we exhausted iterations, return best result found 

433 # BUT: ensure we didn't return the same position (no backward progress) 

434 final_page = best_page if best_page else page 

435 final_start = best_start 

436 

437 # Safety check: if final_start >= end_position, we failed to move backward 

438 # This can happen at the beginning of the document or when estimation failed 

439 if self._position_compare(final_start, end_position) >= 0: 439 ↛ 441line 439 didn't jump to line 441 because the condition on line 439 was never true

440 # Can't go further back - check if we're at the absolute beginning 

441 if end_position.block_index == 0 and end_position.word_index == 0: 

442 # Already at beginning, return as-is 

443 return final_page, final_start 

444 

445 # Fallback strategy: try a more aggressive backward jump 

446 # Start from several blocks before the current position 

447 blocks_to_jump = max(1, min(5, end_position.block_index)) 

448 fallback_pos = RenderingPosition( 

449 chapter_index=end_position.chapter_index, 

450 block_index=max(0, end_position.block_index - blocks_to_jump), 

451 word_index=0 

452 ) 

453 

454 # Render forward from the fallback position 

455 fallback_page, fallback_end = self.render_page_forward(fallback_pos, font_scale) 

456 

457 # Verify the fallback actually moved us backward 

458 if self._position_compare(fallback_pos, end_position) < 0: 

459 return fallback_page, fallback_pos 

460 

461 # If even the fallback didn't work, we're likely at the beginning 

462 # Return a page starting from the beginning 

463 return self.render_page_forward(RenderingPosition(), font_scale) 

464 

465 return final_page, final_start 

466 

467 def _scale_block_fonts(self, block: Block, font_scale: float) -> Block: 

468 """Apply font scaling and font family override to all fonts in a block""" 

469 # Check if we need to do any transformation 

470 if font_scale == 1.0 and self.font_family_override is None: 

471 return block 

472 

473 # This is a simplified implementation 

474 # In practice, we'd need to handle each block type appropriately 

475 if isinstance(block, (Paragraph, Heading)): 475 ↛ 491line 475 didn't jump to line 491 because the condition on line 475 was always true

476 scaled_block_style = FontScaler.scale_font(block.style, font_scale, self.font_family_override) 

477 if isinstance(block, Heading): 

478 scaled_block = Heading(block.level, scaled_block_style) 

479 else: 

480 scaled_block = Paragraph(scaled_block_style) 

481 

482 # words_iter() returns tuples of (position, word) 

483 for position, word in block.words_iter(): 

484 if isinstance(word, Word): 484 ↛ 483line 484 didn't jump to line 483 because the condition on line 484 was always true

485 scaled_word = Word( 

486 word.text, FontScaler.scale_font( 

487 word.style, font_scale, self.font_family_override)) 

488 scaled_block.add_word(scaled_word) 

489 return scaled_block 

490 

491 return block 

492 

493 def _layout_block_on_page(self, 

494 block: Block, 

495 page: Page, 

496 position: RenderingPosition, 

497 font_scale: float) -> Tuple[bool, 

498 RenderingPosition]: 

499 """ 

500 Try to layout a block on the page starting from the given position. 

501 

502 Returns: 

503 Tuple of (success, new_position) 

504 """ 

505 if isinstance(block, Paragraph): 

506 return self._layout_paragraph_on_page(block, page, position, font_scale) 

507 elif isinstance(block, Heading): 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 return self._layout_heading_on_page(block, page, position, font_scale) 

509 elif isinstance(block, Table): 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 return self._layout_table_on_page(block, page, position, font_scale) 

511 elif isinstance(block, HList): 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true

512 return self._layout_list_on_page(block, page, position, font_scale) 

513 elif isinstance(block, Image): 

514 return self._layout_image_on_page(block, page, position, font_scale) 

515 else: 

516 # Skip unknown block types 

517 new_pos = position.copy() 

518 new_pos.block_index += 1 

519 return True, new_pos 

520 

521 def _layout_paragraph_on_page(self, 

522 paragraph: Paragraph, 

523 page: Page, 

524 position: RenderingPosition, 

525 font_scale: float) -> Tuple[bool, 

526 RenderingPosition]: 

527 """ 

528 Layout a paragraph on the page using the core paragraph_layouter. 

529 Integrates font scaling and position tracking with the proven layout logic. 

530 

531 Args: 

532 paragraph: The paragraph to layout (already scaled if font_scale != 1.0) 

533 page: The page to layout on 

534 position: Current rendering position 

535 font_scale: Font scaling factor (used for context, paragraph should already be scaled) 

536 

537 Returns: 

538 Tuple of (success, new_position) 

539 """ 

540 # Convert remaining_pretext from string to Text object if needed 

541 pretext_obj = None 

542 if position.remaining_pretext: 

543 # Create a Text object from the pretext string 

544 pretext_obj = Text( 

545 position.remaining_pretext, 

546 paragraph.style, 

547 page.draw, 

548 line=None, 

549 source=None 

550 ) 

551 

552 # Call the core paragraph layouter with alignment override if set 

553 success, failed_word_index, remaining_pretext = paragraph_layouter( 

554 paragraph, 

555 page, 

556 start_word=position.word_index, 

557 pretext=pretext_obj, 

558 alignment_override=self.alignment_override 

559 ) 

560 

561 # Create new position based on the result 

562 new_pos = position.copy() 

563 

564 if success: 

565 # Paragraph was fully laid out, move to next block 

566 new_pos.block_index += 1 

567 new_pos.word_index = 0 

568 new_pos.remaining_pretext = None 

569 return True, new_pos 

570 else: 

571 # Paragraph was not fully laid out 

572 if failed_word_index is not None: 572 ↛ 586line 572 didn't jump to line 586 because the condition on line 572 was always true

573 # Update position to the word that didn't fit 

574 new_pos.word_index = failed_word_index 

575 

576 # Convert Text object back to string if there's remaining pretext 

577 if remaining_pretext is not None and hasattr(remaining_pretext, 'text'): 577 ↛ 578line 577 didn't jump to line 578 because the condition on line 577 was never true

578 new_pos.remaining_pretext = remaining_pretext.text 

579 else: 

580 new_pos.remaining_pretext = None 

581 

582 return False, new_pos 

583 else: 

584 # No specific word failed, but layout wasn't successful 

585 # This shouldn't normally happen, but handle it gracefully 

586 return False, position 

587 

588 def _layout_heading_on_page(self, 

589 heading: Heading, 

590 page: Page, 

591 position: RenderingPosition, 

592 font_scale: float) -> Tuple[bool, 

593 RenderingPosition]: 

594 """Layout a heading on the page""" 

595 # Similar to paragraph but with heading-specific styling 

596 return self._layout_paragraph_on_page(heading, page, position, font_scale) 

597 

598 def _layout_table_on_page(self, 

599 table: Table, 

600 page: Page, 

601 position: RenderingPosition, 

602 font_scale: float) -> Tuple[bool, 

603 RenderingPosition]: 

604 """Layout a table on the page with column fitting and row continuation""" 

605 # This is a complex operation that would need full table layout logic 

606 # For now, skip tables 

607 new_pos = position.copy() 

608 new_pos.block_index += 1 

609 new_pos.table_row = 0 

610 new_pos.table_col = 0 

611 return True, new_pos 

612 

613 def _layout_list_on_page(self, 

614 hlist: HList, 

615 page: Page, 

616 position: RenderingPosition, 

617 font_scale: float) -> Tuple[bool, 

618 RenderingPosition]: 

619 """Layout a list on the page""" 

620 # This would need list-specific layout logic 

621 # For now, skip lists 

622 new_pos = position.copy() 

623 new_pos.block_index += 1 

624 new_pos.list_item_index = 0 

625 return True, new_pos 

626 

627 def _layout_image_on_page(self, 

628 image: Image, 

629 page: Page, 

630 position: RenderingPosition, 

631 font_scale: float) -> Tuple[bool, 

632 RenderingPosition]: 

633 """ 

634 Layout an image on the page using the image_layouter. 

635 

636 Args: 

637 image: The Image block to layout 

638 page: The page to layout on 

639 position: Current rendering position (should be at the start of this image block) 

640 font_scale: Font scaling factor (not used for images, but kept for consistency) 

641 

642 Returns: 

643 Tuple of (success, new_position) 

644 - success: True if image was laid out, False if page ran out of space 

645 - new_position: Updated position (next block if success, same block if failed) 

646 """ 

647 # Try to layout the image on the current page 

648 success = image_layouter( 

649 image=image, 

650 page=page, 

651 max_width=None, # Use page available width 

652 max_height=None # Use page available height 

653 ) 

654 

655 new_pos = position.copy() 

656 

657 if success: 

658 # Image was successfully laid out, move to next block 

659 new_pos.block_index += 1 

660 new_pos.word_index = 0 

661 return True, new_pos 

662 else: 

663 # Image didn't fit on current page, signal to continue on next page 

664 # Keep same position so it will be attempted on the next page 

665 return False, position 

666 

667 def _estimate_page_start( 

668 self, 

669 end_position: RenderingPosition, 

670 font_scale: float) -> RenderingPosition: 

671 """Estimate where a page should start to end at the given position""" 

672 # This is a simplified heuristic - a full implementation would be more 

673 # sophisticated 

674 estimated_start = end_position.copy() 

675 

676 # Move back by an estimated number of blocks that would fit on a page 

677 estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate 

678 estimated_start.block_index = max( 

679 0, end_position.block_index - estimated_blocks_per_page) 

680 estimated_start.word_index = 0 

681 

682 return estimated_start 

683 

684 def _adjust_start_estimate( 

685 self, 

686 current_start: RenderingPosition, 

687 target_end: RenderingPosition, 

688 actual_end: RenderingPosition) -> RenderingPosition: 

689 """ 

690 Adjust start position estimate based on overshoot/undershoot. 

691 Uses proportional adjustment to converge faster. 

692 """ 

693 adjusted = current_start.copy() 

694 

695 # Calculate the difference between actual and target end positions 

696 block_diff = actual_end.block_index - target_end.block_index 

697 

698 comparison = self._position_compare(actual_end, target_end) 

699 

700 if comparison < 0: # Undershot - rendered to block X but need to reach block Y where X < Y 

701 # We didn't render far enough forward 

702 # Need to start at a LATER block (higher index) so the page includes more content 

703 adjustment = max(1, abs(block_diff) // 2) 

704 new_index = adjusted.block_index + adjustment 

705 # Clamp to valid range 

706 if len(self.blocks) > 0: 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true

707 adjusted.block_index = min(len(self.blocks) - 1, max(0, new_index)) 

708 else: 

709 adjusted.block_index = max(0, new_index) 

710 elif comparison > 0: # Overshot - rendered past the target 

711 # We rendered too far forward 

712 # Need to start at an EARLIER block (lower index) so the page doesn't go as far 

713 adjustment = max(1, abs(block_diff) // 2) 

714 adjusted.block_index = max(0, adjusted.block_index - adjustment) 

715 

716 # Reset word index when adjusting blocks 

717 adjusted.word_index = 0 

718 

719 return adjusted 

720 

721 def _position_compare(self, pos1: RenderingPosition, 

722 pos2: RenderingPosition) -> int: 

723 """Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)""" 

724 if pos1.chapter_index != pos2.chapter_index: 

725 return 1 if pos1.chapter_index > pos2.chapter_index else -1 

726 if pos1.block_index != pos2.block_index: 

727 return 1 if pos1.block_index > pos2.block_index else -1 

728 if pos1.word_index != pos2.word_index: 

729 return 1 if pos1.word_index > pos2.word_index else -1 

730 return 0 

731 

732 

733# Add can_fit_line method to Page class if it doesn't exist 

734def _add_page_methods(): 

735 """Add missing methods to Page class""" 

736 if not hasattr(Page, 'can_fit_line'): 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true

737 def can_fit_line(self, line_height: int) -> bool: 

738 """Check if a line of given height can fit on the page""" 

739 available_height = self.content_size[1] - self._current_y_offset 

740 return available_height >= line_height 

741 

742 Page.can_fit_line = can_fit_line 

743 

744 if not hasattr(Page, 'available_width'): 744 ↛ 745line 744 didn't jump to line 745 because the condition on line 744 was never true

745 @property 

746 def available_width(self) -> int: 

747 """Get available width for content""" 

748 return self.content_size[0] 

749 

750 Page.available_width = available_width 

751 

752 

753# Apply the page methods 

754_add_page_methods()