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

304 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-08 20:34 +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 ( 

19 Block, Paragraph, Heading, HeadingLevel, Table, TableRow, TableCell, 

20 HList, ListItem, Quote, Image) 

21from pyWebLayout.abstract.inline import Word 

22from pyWebLayout.concrete.page import Page 

23from pyWebLayout.concrete.text import Text 

24from pyWebLayout.style.page_style import PageStyle 

25from pyWebLayout.style import Font 

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

27from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter 

28 

29 

30@dataclass 

31class RenderingPosition: 

32 """ 

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

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

35 """ 

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

37 block_index: int = 0 # Which block within chapter 

38 # Which word within block (for paragraphs) 

39 word_index: int = 0 

40 table_row: int = 0 # Which row for tables 

41 table_col: int = 0 # Which column for tables 

42 list_item_index: int = 0 # Which item for lists 

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

44 page_y_offset: int = 0 # Vertical position on page 

45 

46 def _key(self) -> Tuple[Any, ...]: 

47 """ 

48 The fields in declaration order. 

49 

50 Copying, comparing and hashing a position all used to go through 

51 dataclasses.asdict, which walks the field list and deep-copies each value. 

52 Every field here is an immutable scalar, so that traversal bought nothing 

53 and these three run constantly during page navigation and buffer lookups. 

54 """ 

55 return (self.chapter_index, self.block_index, self.word_index, 

56 self.table_row, self.table_col, self.list_item_index, 

57 self.remaining_pretext, self.page_y_offset) 

58 

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

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

61 return asdict(self) 

62 

63 @classmethod 

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

65 """Deserialize position from saved state""" 

66 return cls(**data) 

67 

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

69 """Create a copy of this position""" 

70 return RenderingPosition(*self._key()) 

71 

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

73 """Check if two positions are equal""" 

74 if not isinstance(other, RenderingPosition): 

75 return False 

76 return self._key() == other._key() 

77 

78 def __hash__(self) -> int: 

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

80 return hash(self._key()) 

81 

82 

83class ChapterInfo: 

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

85 

86 def __init__( 

87 self, 

88 title: str, 

89 level: HeadingLevel, 

90 position: RenderingPosition, 

91 block_index: int): 

92 self.title = title 

93 self.level = level 

94 self.position = position 

95 self.block_index = block_index 

96 

97 

98class ChapterNavigator: 

99 """ 

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

101 Builds a table of contents and provides navigation capabilities. 

102 """ 

103 

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

105 self.blocks = blocks 

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

107 self._build_chapter_map() 

108 

109 def _build_chapter_map(self): 

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

111 current_chapter_index = 0 

112 

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

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

115 cover_position = RenderingPosition( 

116 chapter_index=0, 

117 block_index=0, 

118 word_index=0, 

119 table_row=0, 

120 table_col=0, 

121 list_item_index=0 

122 ) 

123 

124 cover_info = ChapterInfo( 

125 title="Cover", 

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

127 position=cover_position, 

128 block_index=0 

129 ) 

130 

131 self.chapters.append(cover_info) 

132 

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

134 if isinstance(block, Heading): 

135 # Create position for this heading 

136 position = RenderingPosition( 

137 chapter_index=current_chapter_index, 

138 block_index=block_index, # Use actual block index 

139 word_index=0, 

140 table_row=0, 

141 table_col=0, 

142 list_item_index=0 

143 ) 

144 

145 # Extract heading text 

146 heading_text = self._extract_heading_text(block) 

147 

148 chapter_info = ChapterInfo( 

149 title=heading_text, 

150 level=block.level, 

151 position=position, 

152 block_index=block_index 

153 ) 

154 

155 self.chapters.append(chapter_info) 

156 

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

158 if block.level == HeadingLevel.H1: 

159 current_chapter_index += 1 

160 

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

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

163 words = [] 

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

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

166 words.append(word.text) 

167 return " ".join(words) 

168 

169 def get_table_of_contents( 

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

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

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

173 for chapter in self.chapters] 

174 

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

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

177 for chapter in self.chapters: 

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

179 return chapter.position 

180 return None 

181 

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

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

184 if not self.chapters: 

185 return None 

186 

187 # Find the chapter that contains this position 

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

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

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

191 return chapter 

192 

193 next_chapter = self.chapters[i + 1] 

194 if position.chapter_index < next_chapter.position.chapter_index: 

195 return chapter 

196 

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

198 

199 

200class FontFamilyOverride: 

201 """ 

202 Manages font family preferences for ereader rendering. 

203 Allows dynamic font family switching without modifying source blocks. 

204 """ 

205 

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

207 """ 

208 Initialize font family override. 

209 

210 Args: 

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

212 """ 

213 self.preferred_family = preferred_family 

214 

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

216 """ 

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

218 

219 Args: 

220 font: Original font object 

221 

222 Returns: 

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

224 """ 

225 if self.preferred_family is None: 

226 return font 

227 

228 # Get the appropriate font path for the preferred family 

229 # preserving the original font's weight and style 

230 new_font_path = get_bundled_font_path( 

231 family=self.preferred_family, 

232 weight=font.weight, 

233 style=font.style 

234 ) 

235 

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

237 if new_font_path is None: 

238 return font 

239 

240 # Create a new font with the overridden path 

241 return Font( 

242 font_path=new_font_path, 

243 font_size=font.font_size, 

244 colour=font.colour, 

245 weight=font.weight, 

246 style=font.style, 

247 decoration=font.decoration, 

248 background=font.background, 

249 language=font.language, 

250 min_hyphenation_width=font.min_hyphenation_width 

251 ) 

252 

253 

254class FontScaler: 

255 """ 

256 Handles font scaling operations for ereader font size adjustments. 

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

258 """ 

259 

260 @staticmethod 

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

262 """ 

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

264 

265 Args: 

266 font: Original font object 

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

268 family_override: Optional font family override 

269 

270 Returns: 

271 New Font object with scaled size and optional family override 

272 """ 

273 # Apply family override first if specified 

274 working_font = font 

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

276 working_font = family_override.override_font(font) 

277 

278 # Then apply scaling 

279 if scale_factor == 1.0: 

280 return working_font 

281 

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

283 

284 return Font( 

285 font_path=working_font._font_path, 

286 font_size=scaled_size, 

287 colour=working_font.colour, 

288 weight=working_font.weight, 

289 style=working_font.style, 

290 decoration=working_font.decoration, 

291 background=working_font.background, 

292 language=working_font.language, 

293 min_hyphenation_width=working_font.min_hyphenation_width 

294 ) 

295 

296 @staticmethod 

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

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

299 """Scale word spacing constraints proportionally""" 

300 if scale_factor == 1.0: 

301 return spacing 

302 

303 min_spacing, max_spacing = spacing 

304 return ( 

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

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

307 ) 

308 

309 

310class BidirectionalLayouter: 

311 """ 

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

313 Handles font scaling and maintains position state. 

314 """ 

315 

316 def __init__(self, 

317 blocks: List[Block], 

318 page_style: PageStyle, 

319 page_size: Tuple[int, 

320 int] = (800, 

321 600), 

322 alignment_override=None, 

323 font_family_override: Optional[FontFamilyOverride] = None): 

324 self.blocks = blocks 

325 self.page_style = page_style 

326 self.page_size = page_size 

327 self.chapter_navigator = ChapterNavigator(blocks) 

328 self.alignment_override = alignment_override 

329 self.font_family_override = font_family_override 

330 

331 # Maps (font_scale, end position) -> the position the page started at. 

332 # Filled in as pages are laid out forward, which makes "previous page" 

333 # exact and free for anywhere the reader has already been. Keyed by font 

334 # scale because changing it repaginates the document. 

335 self._page_chain: Dict[Tuple[float, Tuple[int, int, int]], 

336 RenderingPosition] = {} 

337 

338 # Scaled copies of blocks, keyed by (id(block), font_scale). Rebuilding 

339 # a block's words on every page render allocated a fresh Paragraph and 

340 # Word per word on the hot path. The original block is kept alongside 

341 # the copy so its id cannot be recycled while it is a live key. 

342 self._scaled_block_cache: Dict[Tuple[int, float], Tuple[Block, Block]] = {} 

343 

344 def render_page_forward(self, position: RenderingPosition, 

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

346 """ 

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

348 

349 Args: 

350 position: Starting position in document 

351 font_scale: Font scaling factor 

352 

353 Returns: 

354 Tuple of (rendered_page, next_position) 

355 """ 

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

357 current_pos = position.copy() 

358 

359 # Start laying out blocks from the current position 

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

361 # Additional bounds check to prevent IndexError 

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

363 break 

364 

365 block = self.blocks[current_pos.block_index] 

366 

367 # Apply font scaling to the block 

368 scaled_block = self._scale_block_fonts(block, font_scale) 

369 

370 # Try to fit the block on the current page 

371 success, new_pos = self._layout_block_on_page( 

372 scaled_block, page, current_pos, font_scale) 

373 

374 if not success: 

375 # The block did not fit in its entirety. It may still have been 

376 # laid out partially - a paragraph larger than one page places as 

377 # many lines as fit and reports the word it stopped at. Keeping 

378 # that resume point is what allows the next page to continue; 

379 # discarding it tells the caller no progress was made, which 

380 # dead-ends navigation on the block forever. 

381 if self._position_compare(new_pos, current_pos) > 0: 

382 current_pos = new_pos 

383 break 

384 

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

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

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

388 page._current_y_offset += self.page_style.inter_block_spacing 

389 

390 # Ensure new position doesn't go beyond bounds 

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

392 # We've reached the end of the document 

393 current_pos = new_pos 

394 break 

395 

396 current_pos = new_pos 

397 

398 # Remember this link in the chain so stepping back to it later is exact. 

399 if self._position_compare(current_pos, position) > 0: 

400 self._page_chain[(font_scale, self._position_key(current_pos))] = \ 

401 position.copy() 

402 

403 return page, current_pos 

404 

405 # How many block starts before the target to try as replay anchors before 

406 # settling for the best inexact answer. 

407 MAX_BACKWARD_ANCHORS = 4 

408 

409 # Ceiling on pages replayed from a single anchor, so a pathologically long 

410 # block cannot make one page turn walk an entire chapter. 

411 MAX_REPLAY_PAGES = 8 

412 

413 def render_page_backward(self, 

414 end_position: RenderingPosition, 

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

416 RenderingPosition]: 

417 """ 

418 Render the page that ends at the given position - "previous page". 

419 

420 Pagination is a pure function: laying out from a position q yields a page 

421 and the position where it stopped, next(q). The page before P is therefore 

422 the q for which next(q) == P, and it is found by *replaying* the chain 

423 forward from an anchor, not by guessing q. 

424 

425 The previous implementation searched instead: it estimated a block index 

426 and bisected on it, pinning word_index to 0. Pages routinely start 

427 mid-block, so the answer was frequently not in the search space at all - 

428 the search then exhausted its iterations and fell back to a position that 

429 was not the previous page, usually the start of the document. 

430 

431 Three sources are tried in order: 

432 

433 1. The recorded chain, from pages already laid out going forward. Exact, 

434 and the common case when the reader is paging back and forth. 

435 2. Replay from the start of the block containing P, then from 

436 progressively earlier blocks. Exact when P lies on the resulting chain. 

437 3. Failing an exact hit - which happens when P was reached by a jump or a 

438 restored bookmark rather than by reading forward, so it is on no 

439 natural chain - the latest page start before P. That overlaps P's page 

440 slightly rather than skipping content, which is the safe direction to 

441 be wrong in. 

442 

443 Args: 

444 end_position: Position where the page should end 

445 font_scale: Font scaling factor 

446 

447 Returns: 

448 Tuple of (rendered_page, start_position) 

449 """ 

450 document_start = RenderingPosition() 

451 

452 # Nothing precedes the start of the document. 

453 if self._position_compare(end_position, document_start) <= 0: 

454 page, _ = self.render_page_forward(document_start, font_scale) 

455 return page, document_start 

456 

457 # 1. The chain we have already walked. 

458 remembered = self._page_chain.get((font_scale, self._position_key(end_position))) 

459 if remembered is not None: 

460 page, actual_end = self.render_page_forward(remembered, font_scale) 

461 if self._position_compare(actual_end, end_position) == 0: 461 ↛ 465line 461 didn't jump to line 465 because the condition on line 461 was always true

462 return page, remembered 

463 

464 # 2/3. Replay from anchors, keeping the best inexact result as a fallback. 

465 fallback = None 

466 for anchor in self._backward_anchors(end_position): 

467 page, start, exact = self._replay_to(anchor, end_position, font_scale) 

468 if page is None: 

469 continue 

470 if exact: 470 ↛ 472line 470 didn't jump to line 472 because the condition on line 470 was always true

471 return page, start 

472 if fallback is None: 

473 fallback = (page, start) 

474 

475 if fallback is not None: 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true

476 return fallback 

477 

478 page, _ = self.render_page_forward(document_start, font_scale) 

479 return page, document_start 

480 

481 def _backward_anchors(self, target: RenderingPosition): 

482 """ 

483 Yield positions to replay from, nearest first. 

484 

485 Block starts are used as anchors because they are the coarsest positions 

486 that are certainly valid to lay out from. The block containing the target 

487 comes first: when the target is mid-block, the page before it usually 

488 starts in that same block or the one before. 

489 """ 

490 first_block = target.block_index if target.word_index > 0 \ 

491 else target.block_index - 1 

492 

493 for offset in range(self.MAX_BACKWARD_ANCHORS): 

494 block_index = first_block - offset 

495 if block_index < 0: 

496 break 

497 yield RenderingPosition( 

498 chapter_index=target.chapter_index, 

499 block_index=block_index, 

500 word_index=0, 

501 ) 

502 

503 if first_block - self.MAX_BACKWARD_ANCHORS >= 0: 

504 yield RenderingPosition() 

505 

506 def _replay_to(self, 

507 anchor: RenderingPosition, 

508 target: RenderingPosition, 

509 font_scale: float): 

510 """ 

511 Lay out pages forward from `anchor`, looking for the one ending at `target`. 

512 

513 Returns: 

514 (page, start, exact). `exact` is True when a page ended precisely on 

515 the target. When the chain steps over the target instead, the last 

516 page starting before it is returned with exact=False. (None, None, 

517 False) means the anchor yielded nothing usable. 

518 """ 

519 position = anchor 

520 last = (None, None) 

521 

522 for _ in range(self.MAX_REPLAY_PAGES): 522 ↛ 542line 522 didn't jump to line 542 because the loop on line 522 didn't complete

523 if self._position_compare(position, target) >= 0: 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true

524 break 

525 

526 page, next_position = self.render_page_forward(position, font_scale) 

527 comparison = self._position_compare(next_position, target) 

528 

529 if comparison == 0: 

530 return page, position, True 

531 

532 if comparison > 0: 

533 # Stepped over the target: this chain does not pass through it. 

534 return last[0], last[1], False 

535 

536 if self._position_compare(next_position, position) <= 0: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true

537 break # no progress; give up on this anchor 

538 

539 last = (page, position) 

540 position = next_position 

541 

542 return last[0], last[1], False 

543 

544 @staticmethod 

545 def _position_key(position: RenderingPosition) -> Tuple[int, int, int]: 

546 """Hashable identity of a position, for the page chain map.""" 

547 return (position.chapter_index, position.block_index, position.word_index) 

548 

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

550 """ 

551 Apply font scaling and the font family override to every font in a block. 

552 

553 Returns the block unchanged when there is nothing to apply. Results are 

554 memoised per (block, scale) for the life of the layouter, so a page 

555 re-render at an unchanged scale costs a dict lookup. 

556 """ 

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

558 return block 

559 

560 key = (id(block), font_scale) 

561 cached = self._scaled_block_cache.get(key) 

562 if cached is not None: 

563 return cached[1] 

564 

565 scaled = self._build_scaled_block(block, font_scale) 

566 self._scaled_block_cache[key] = (block, scaled) 

567 return scaled 

568 

569 def _build_scaled_block(self, block: Block, font_scale: float) -> Block: 

570 """Construct the scaled copy of a block. See _scale_block_fonts.""" 

571 def scale(font: Font) -> Font: 

572 return FontScaler.scale_font(font, font_scale, self.font_family_override) 

573 

574 if isinstance(block, (Paragraph, Heading)): 

575 if isinstance(block, Heading): 

576 scaled_block = Heading(block.level, scale(block.style)) 

577 else: 

578 scaled_block = Paragraph(scale(block.style)) 

579 

580 # words_iter() yields (position, word) tuples. with_style() keeps 

581 # the concrete word class, so a LinkedWord stays linked - rebuilding 

582 # these as plain Words silently stripped every hyperlink in the 

583 # document as soon as the reader changed font size. 

584 for _, word in block.words_iter(): 

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

586 scaled_block.add_word(word.with_style(scale(word.style))) 

587 return scaled_block 

588 

589 if isinstance(block, Quote): 

590 scaled_quote = Quote(scale(block.style) if block.style else None) 

591 for child in block.blocks(): 

592 scaled_quote.add_block(self._scale_block_fonts(child, font_scale)) 

593 return scaled_quote 

594 

595 if isinstance(block, HList): 

596 scaled_list = HList( 

597 block.style, 

598 scale(block.default_style) if block.default_style else None) 

599 for item in block.items(): 

600 scaled_item = ListItem( 

601 item.term, 

602 scale(item.style) if item.style else None) 

603 for child in item.blocks(): 

604 scaled_item.add_block(self._scale_block_fonts(child, font_scale)) 

605 scaled_list.add_item(scaled_item) 

606 return scaled_list 

607 

608 if isinstance(block, Table): 608 ↛ 633line 608 didn't jump to line 633 because the condition on line 608 was always true

609 scaled_table = Table( 

610 block.caption, 

611 scale(block.style) if block.style else None) 

612 # Rows must go back into the section they came from, or a <thead> 

613 # row would be re-added as a body row. 

614 for section, rows in (('header', block.header_rows()), 

615 ('body', block.body_rows()), 

616 ('footer', block.footer_rows())): 

617 for row in rows: 

618 scaled_row = TableRow(scale(row.style) if row.style else None) 

619 for cell in row.cells(): 

620 scaled_cell = TableCell( 

621 is_header=cell.is_header, 

622 colspan=cell.colspan, 

623 rowspan=cell.rowspan, 

624 style=scale(cell.style) if cell.style else None) 

625 for child in cell.blocks(): 

626 scaled_cell.add_block(self._scale_block_fonts(child, font_scale)) 

627 scaled_row.add_cell(scaled_cell) 

628 scaled_table.add_row(scaled_row, section) 

629 return scaled_table 

630 

631 # Blocks with no fonts of their own (Image, HorizontalRule, PageBreak, 

632 # CodeBlock - which carries raw lines, not styled words) pass through. 

633 return block 

634 

635 def _layout_block_on_page(self, 

636 block: Block, 

637 page: Page, 

638 position: RenderingPosition, 

639 font_scale: float) -> Tuple[bool, 

640 RenderingPosition]: 

641 """ 

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

643 

644 Returns: 

645 Tuple of (success, new_position) 

646 """ 

647 if isinstance(block, Paragraph): 

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

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

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

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

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

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

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

655 elif isinstance(block, Image): 

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

657 else: 

658 # Skip unknown block types 

659 new_pos = position.copy() 

660 new_pos.block_index += 1 

661 return True, new_pos 

662 

663 def _layout_paragraph_on_page(self, 

664 paragraph: Paragraph, 

665 page: Page, 

666 position: RenderingPosition, 

667 font_scale: float) -> Tuple[bool, 

668 RenderingPosition]: 

669 """ 

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

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

672 

673 Args: 

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

675 page: The page to layout on 

676 position: Current rendering position 

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

678 

679 Returns: 

680 Tuple of (success, new_position) 

681 """ 

682 # Convert remaining_pretext from string to Text object if needed 

683 pretext_obj = None 

684 if position.remaining_pretext: 

685 # Create a Text object from the pretext string 

686 pretext_obj = Text( 

687 position.remaining_pretext, 

688 paragraph.style, 

689 page.draw, 

690 line=None, 

691 source=None 

692 ) 

693 

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

695 success, failed_word_index, remaining_pretext = paragraph_layouter( 

696 paragraph, 

697 page, 

698 start_word=position.word_index, 

699 pretext=pretext_obj, 

700 alignment_override=self.alignment_override 

701 ) 

702 

703 # Create new position based on the result 

704 new_pos = position.copy() 

705 

706 if success: 

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

708 new_pos.block_index += 1 

709 new_pos.word_index = 0 

710 new_pos.remaining_pretext = None 

711 return True, new_pos 

712 else: 

713 # Paragraph was not fully laid out 

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

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

716 new_pos.word_index = failed_word_index 

717 

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

719 if remaining_pretext is not None and hasattr(remaining_pretext, 'text'): 

720 new_pos.remaining_pretext = remaining_pretext.text 

721 else: 

722 new_pos.remaining_pretext = None 

723 

724 return False, new_pos 

725 else: 

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

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

728 return False, position 

729 

730 def _layout_heading_on_page(self, 

731 heading: Heading, 

732 page: Page, 

733 position: RenderingPosition, 

734 font_scale: float) -> Tuple[bool, 

735 RenderingPosition]: 

736 """Layout a heading on the page""" 

737 # Similar to paragraph but with heading-specific styling 

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

739 

740 def _layout_table_on_page(self, 

741 table: Table, 

742 page: Page, 

743 position: RenderingPosition, 

744 font_scale: float) -> Tuple[bool, 

745 RenderingPosition]: 

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

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

748 # For now, skip tables 

749 new_pos = position.copy() 

750 new_pos.block_index += 1 

751 new_pos.table_row = 0 

752 new_pos.table_col = 0 

753 return True, new_pos 

754 

755 def _layout_list_on_page(self, 

756 hlist: HList, 

757 page: Page, 

758 position: RenderingPosition, 

759 font_scale: float) -> Tuple[bool, 

760 RenderingPosition]: 

761 """Layout a list on the page""" 

762 # This would need list-specific layout logic 

763 # For now, skip lists 

764 new_pos = position.copy() 

765 new_pos.block_index += 1 

766 new_pos.list_item_index = 0 

767 return True, new_pos 

768 

769 def _layout_image_on_page(self, 

770 image: Image, 

771 page: Page, 

772 position: RenderingPosition, 

773 font_scale: float) -> Tuple[bool, 

774 RenderingPosition]: 

775 """ 

776 Layout an image on the page using the image_layouter. 

777 

778 Args: 

779 image: The Image block to layout 

780 page: The page to layout on 

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

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

783 

784 Returns: 

785 Tuple of (success, new_position) 

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

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

788 """ 

789 # Try to layout the image on the current page 

790 success = image_layouter( 

791 image=image, 

792 page=page, 

793 max_width=None, # Use page available width 

794 max_height=None # Use page available height 

795 ) 

796 

797 new_pos = position.copy() 

798 

799 if success: 

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

801 new_pos.block_index += 1 

802 new_pos.word_index = 0 

803 return True, new_pos 

804 else: 

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

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

807 return False, position 

808 

809 def _position_compare(self, pos1: RenderingPosition, 

810 pos2: RenderingPosition) -> int: 

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

812 if pos1.chapter_index != pos2.chapter_index: 

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

814 if pos1.block_index != pos2.block_index: 

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

816 if pos1.word_index != pos2.word_index: 

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

818 return 0