added fotn change API and examples
All checks were successful
Python CI / test (3.10) (push) Successful in 2m18s
Python CI / test (3.12) (push) Successful in 2m8s
Python CI / test (3.13) (push) Successful in 2m6s

This commit is contained in:
Duncan Tourolle 2025-11-11 12:44:18 +01:00
parent 9de67d958e
commit 889f27e1a3
8 changed files with 732 additions and 29 deletions

View File

@ -25,6 +25,7 @@ PyWebLayout is a Python library for HTML-like layout and rendering to paginated
### Text and HTML Support ### Text and HTML Support
- 📝 **HTML Parsing** - Parse HTML content into structured document blocks - 📝 **HTML Parsing** - Parse HTML content into structured document blocks
- 🔤 **Font Support** - Multiple font sizes, weights, and styles - 🔤 **Font Support** - Multiple font sizes, weights, and styles
- 🎨 **Dynamic Font Families** - Switch between Sans, Serif, and Monospace fonts on-the-fly
- ↔️ **Text Alignment** - Left, center, right, and justified text - ↔️ **Text Alignment** - Left, center, right, and justified text
- 📖 **Rich Content** - Headings, paragraphs, bold, italic, and more - 📖 **Rich Content** - Headings, paragraphs, bold, italic, and more
- 📊 **Table Rendering** - Full HTML table support with headers, borders, and styling - 📊 **Table Rendering** - Full HTML table support with headers, borders, and styling
@ -138,6 +139,13 @@ The library supports various page layouts and configurations:
<em>All 14 form field types with validation</em> <em>All 14 form field types with validation</em>
</td> </td>
</tr> </tr>
<tr>
<td align="center" colspan="2">
<b>🆕 Dynamic Font Family Switching</b><br>
<img src="docs/images/font_family_switching_vertical.png" width="600" alt="Font Switching"><br>
<em>Switch between Sans, Serif, and Monospace fonts instantly</em>
</td>
</tr>
</table> </table>
## Examples ## Examples
@ -151,11 +159,13 @@ The `examples/` directory contains working demonstrations:
- **[04_table_rendering.py](examples/04_table_rendering.py)** - HTML table rendering with styling - **[04_table_rendering.py](examples/04_table_rendering.py)** - HTML table rendering with styling
- **[05_html_table_with_images.py](examples/05_html_table_with_images.py)** - Tables with embedded images - **[05_html_table_with_images.py](examples/05_html_table_with_images.py)** - Tables with embedded images
- **[06_functional_elements_demo.py](examples/06_functional_elements_demo.py)** - Interactive buttons and forms with callbacks - **[06_functional_elements_demo.py](examples/06_functional_elements_demo.py)** - Interactive buttons and forms with callbacks
- **[08_bundled_fonts_demo.py](examples/08_bundled_fonts_demo.py)** - Using the bundled DejaVu font families
### 🆕 Advanced Features (NEW) ### 🆕 Advanced Features (NEW)
- **[08_pagination_demo.py](examples/08_pagination_demo.py)** - Multi-page documents with PageBreak ([11 tests](tests/examples/test_08_pagination_demo.py)) - **[08_pagination_demo.py](examples/08_pagination_demo.py)** - Multi-page documents with PageBreak ([11 tests](tests/examples/test_08_pagination_demo.py))
- **[09_link_navigation_demo.py](examples/09_link_navigation_demo.py)** - All link types and navigation ([10 tests](tests/examples/test_09_link_navigation_demo.py)) - **[09_link_navigation_demo.py](examples/09_link_navigation_demo.py)** - All link types and navigation ([10 tests](tests/examples/test_09_link_navigation_demo.py))
- **[10_forms_demo.py](examples/10_forms_demo.py)** - All 14 form field types ([9 tests](tests/examples/test_10_forms_demo.py)) - **[10_forms_demo.py](examples/10_forms_demo.py)** - All 14 form field types ([9 tests](tests/examples/test_10_forms_demo.py))
- **[11_font_family_switching_demo.py](examples/11_font_family_switching_demo.py)** - 🆕 Dynamic font switching in ereader
Run any example: Run any example:
```bash ```bash
@ -176,9 +186,46 @@ python -m pytest tests/examples/ -v # 30 tests, all passing ✅
See **[examples/README.md](examples/README.md)** for detailed documentation. See **[examples/README.md](examples/README.md)** for detailed documentation.
## Font Family Switching (NEW ✨)
PyWebLayout now supports dynamic font family switching in the ereader, allowing readers to change fonts on-the-fly without losing their reading position!
### Quick Example
```python
from pyWebLayout.style.fonts import BundledFont
from pyWebLayout.layout.ereader_manager import create_ereader_manager
# Create an ereader
manager = create_ereader_manager(blocks, page_size=(600, 800))
# Switch to serif font
manager.set_font_family(BundledFont.SERIF)
# Switch to monospace font
manager.set_font_family(BundledFont.MONOSPACE)
# Restore original fonts
manager.set_font_family(None)
# Query current font
current = manager.get_font_family()
```
### Features
- **3 Bundled Fonts**: Sans, Serif, and Monospace (DejaVu font family)
- **Instant Switching**: Change fonts without recreating the document
- **Position Preservation**: Reading position maintained across font changes
- **Attribute Preservation**: Bold, italic, size, and color are preserved
- **Smart Caching**: Automatic cache invalidation for optimal performance
**Learn more**: See [FONT_SWITCHING_FEATURE.md](FONT_SWITCHING_FEATURE.md) for complete documentation.
## Documentation ## Documentation
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Abstract/Concrete architecture guide - **[ARCHITECTURE.md](ARCHITECTURE.md)** - Abstract/Concrete architecture guide
- **[FONT_SWITCHING_FEATURE.md](FONT_SWITCHING_FEATURE.md)** - 🆕 Font family switching guide
- **[examples/README.md](examples/README.md)** - Complete examples guide with tests - **[examples/README.md](examples/README.md)** - Complete examples guide with tests
- **[docs/images/README.md](docs/images/README.md)** - Visual documentation index - **[docs/images/README.md](docs/images/README.md)** - Visual documentation index
- **[pyWebLayout/layout/README_EREADER_API.md](pyWebLayout/layout/README_EREADER_API.md)** - EbookReader API reference - **[pyWebLayout/layout/README_EREADER_API.md](pyWebLayout/layout/README_EREADER_API.md)** - EbookReader API reference

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,240 @@
"""
Demonstration of dynamic font family switching in the ereader.
This example shows how to:
1. Initialize an ereader with content
2. Dynamically switch between different font families (Sans, Serif, Monospace)
3. Maintain reading position across font changes
4. Use the font family API
The ereader manager provides a high-level API for changing fonts on-the-fly
without losing your place in the document.
"""
from pyWebLayout.abstract import Paragraph, Heading, Word
from pyWebLayout.abstract.block import HeadingLevel
from pyWebLayout.style import Font
from pyWebLayout.style.fonts import BundledFont, FontWeight
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.ereader_manager import create_ereader_manager
from PIL import Image
def create_sample_content():
"""Create sample document content with various text styles"""
blocks = []
# Create a default font for the content
default_font = Font.from_family(BundledFont.SANS, font_size=16)
heading_font = Font.from_family(BundledFont.SANS, font_size=24, weight=FontWeight.BOLD)
# Title
title = Heading(level=HeadingLevel.H1, style=heading_font)
for word in "Font Family Switching Demo".split():
title.add_word(Word(word, heading_font))
blocks.append(title)
# Introduction paragraph
intro_font = Font.from_family(BundledFont.SANS, font_size=16)
intro = Paragraph(intro_font)
intro_text = (
"This demonstration shows how the ereader can dynamically switch between "
"different font families while maintaining your reading position. "
"The three bundled font families (Sans, Serif, and Monospace) can be "
"changed on-the-fly without recreating the document."
)
for word in intro_text.split():
intro.add_word(Word(word, intro_font))
blocks.append(intro)
# Section 1
section1_heading = Heading(level=HeadingLevel.H2, style=heading_font)
for word in "Sans-Serif Font".split():
section1_heading.add_word(Word(word, heading_font))
blocks.append(section1_heading)
para1 = Paragraph(default_font)
text1 = (
"Sans-serif fonts like DejaVu Sans are clean and modern, making them "
"ideal for screen reading. They lack the decorative strokes (serifs) "
"found in traditional typefaces, which can improve legibility on digital displays. "
"Many ereader applications default to sans-serif fonts for this reason."
)
for word in text1.split():
para1.add_word(Word(word, default_font))
blocks.append(para1)
# Section 2
section2_heading = Heading(level=HeadingLevel.H2, style=heading_font)
for word in "Serif Font".split():
section2_heading.add_word(Word(word, heading_font))
blocks.append(section2_heading)
para2 = Paragraph(default_font)
text2 = (
"Serif fonts like DejaVu Serif have small decorative strokes at the ends "
"of letter strokes. These fonts are traditionally used in print media and "
"can give a more formal, classic appearance. Many readers prefer serif fonts "
"for long-form reading as they find them easier on the eyes."
)
for word in text2.split():
para2.add_word(Word(word, default_font))
blocks.append(para2)
# Section 3
section3_heading = Heading(level=HeadingLevel.H2, style=heading_font)
for word in "Monospace Font".split():
section3_heading.add_word(Word(word, heading_font))
blocks.append(section3_heading)
para3 = Paragraph(default_font)
text3 = (
"Monospace fonts like DejaVu Sans Mono have equal spacing between all characters. "
"They are commonly used for displaying code, technical documentation, and typewriter-style "
"text. While less common for general reading, some users prefer the uniform character "
"spacing for certain types of content."
)
for word in text3.split():
para3.add_word(Word(word, default_font))
blocks.append(para3)
# Final paragraph
conclusion = Paragraph(default_font)
conclusion_text = (
"The ability to switch fonts dynamically is a key feature of modern ereaders. "
"It allows readers to customize their reading experience based on personal preference, "
"lighting conditions, and content type. Try switching between the three font families "
"to see which one you prefer for different types of reading."
)
for word in conclusion_text.split():
conclusion.add_word(Word(word, default_font))
blocks.append(conclusion)
return blocks
def render_pages_with_different_fonts(manager, output_prefix="demo_11"):
"""Render the same page with different font families"""
print("\nRendering pages with different font families...")
print("=" * 70)
font_families = [
(None, "Original (Sans)"),
(BundledFont.SERIF, "Serif"),
(BundledFont.MONOSPACE, "Monospace"),
(BundledFont.SANS, "Sans (explicit)")
]
images = []
for font_family, name in font_families:
print(f"\nRendering with {name} font...")
# Switch font family
manager.set_font_family(font_family)
# Get current page
page = manager.get_current_page()
# Render to image
image = page.render()
filename = f"{output_prefix}_{name.lower().replace(' ', '_').replace('(', '').replace(')', '')}.png"
image.save(filename)
print(f" Saved: {filename}")
images.append((name, image))
return images
def demonstrate_font_switching():
"""Main demonstration function"""
print("\n")
print("=" * 70)
print("Font Family Switching Demonstration")
print("=" * 70)
print()
# Create sample content
print("Creating sample document...")
blocks = create_sample_content()
print(f" Created {len(blocks)} blocks")
# Initialize ereader manager
print("\nInitializing ereader manager...")
page_size = (600, 800)
manager = create_ereader_manager(
blocks,
page_size,
document_id="font_switching_demo"
)
print(f" Page size: {page_size[0]}x{page_size[1]}")
print(f" Initial font family: {manager.get_font_family()}")
# Render pages with different fonts
images = render_pages_with_different_fonts(manager)
# Show position info
print("\nPosition information after font switches:")
print(" " + "-" * 66)
pos_info = manager.get_position_info()
print(f" Current position: Block {pos_info['position']['block_index']}, "
f"Word {pos_info['position']['word_index']}")
print(f" Font family: {pos_info['font_family'] or 'Original'}")
print(f" Font scale: {pos_info['font_scale']}")
print(f" Reading progress: {pos_info['progress']:.1%}")
# Test navigation with font switching
print("\nTesting navigation with font switching...")
print(" " + "-" * 66)
# Reset to beginning
manager.jump_to_position(manager.current_position.__class__())
# Advance a few pages with serif font
manager.set_font_family(BundledFont.SERIF)
print(f" Switched to SERIF font")
for i in range(3):
next_page = manager.next_page()
if next_page:
print(f" Page {i+2}: Advanced successfully")
# Switch to monospace
manager.set_font_family(BundledFont.MONOSPACE)
print(f" Switched to MONOSPACE font")
current_page = manager.get_current_page()
print(f" Re-rendered current page with new font")
# Go back a page
prev_page = manager.previous_page()
if prev_page:
print(f" Navigated back successfully")
# Cache statistics
print("\nCache statistics:")
print(" " + "-" * 66)
stats = manager.get_cache_stats()
for key, value in stats.items():
print(f" {key}: {value}")
print()
print("=" * 70)
print("Demo complete!")
print()
print("Key features demonstrated:")
print(" ✓ Dynamic font family switching (Sans, Serif, Monospace)")
print(" ✓ Position preservation across font changes")
print(" ✓ Automatic cache invalidation on font change")
print(" ✓ Navigation with different fonts")
print(" ✓ Font family info in position tracking")
print()
print("The rendered pages show the same content in different font families.")
print("Notice how the layout adapts while maintaining readability.")
print("=" * 70)
print()
if __name__ == "__main__":
demonstrate_font_switching()

View File

@ -0,0 +1,252 @@
"""
Generate a demo image for README.md showing font family switching feature.
Creates a side-by-side comparison of the same content rendered in
Sans, Serif, and Monospace fonts.
"""
from pyWebLayout.abstract import Paragraph, Heading, Word
from pyWebLayout.abstract.block import HeadingLevel
from pyWebLayout.style import Font
from pyWebLayout.style.fonts import BundledFont, FontWeight
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.ereader_manager import create_ereader_manager
from PIL import Image, ImageDraw, ImageFont
def create_demo_content():
"""Create concise demo content that fits nicely on a small page"""
blocks = []
# Title
title_font = Font.from_family(BundledFont.SANS, font_size=28, weight=FontWeight.BOLD)
title = Heading(level=HeadingLevel.H1, style=title_font)
for word in "The Adventure Begins".split():
title.add_word(Word(word, title_font))
blocks.append(title)
# Paragraph
body_font = Font.from_family(BundledFont.SANS, font_size=14)
para = Paragraph(body_font)
text = (
"In the quiet village of Millbrook, young Emma discovered an ancient map "
"hidden in her grandmother's attic. The parchment revealed a mysterious "
"forest path marked with symbols she had never seen before. With courage "
"in her heart and the map in her pocket, she set out at dawn to uncover "
"the secrets that lay beyond the old oak trees."
)
for word in text.split():
para.add_word(Word(word, body_font))
blocks.append(para)
return blocks
def render_with_font_family(blocks, page_size, font_family, family_name):
"""Render a page with a specific font family"""
manager = create_ereader_manager(
blocks,
page_size,
document_id=f"demo_{family_name.lower()}"
)
# Set font family (None means original/default)
manager.set_font_family(font_family)
# Get the first page
page = manager.get_current_page()
return page.render()
def create_comparison_image():
"""Create a side-by-side comparison of all three font families"""
# Page size for each panel
page_width = 400
page_height = 300
# Create demo content
print("Creating demo content...")
blocks = create_demo_content()
# Render with each font family
print("Rendering with Sans font...")
sans_image = render_with_font_family(
blocks, (page_width, page_height), BundledFont.SANS, "Sans"
)
print("Rendering with Serif font...")
serif_image = render_with_font_family(
blocks, (page_width, page_height), BundledFont.SERIF, "Serif"
)
print("Rendering with Monospace font...")
mono_image = render_with_font_family(
blocks, (page_width, page_height), BundledFont.MONOSPACE, "Monospace"
)
# Create a composite image with all three side by side
spacing = 20
label_height = 30
total_width = page_width * 3 + spacing * 4
total_height = page_height + label_height + spacing * 2
composite = Image.new('RGB', (total_width, total_height), color='#f5f5f5')
# Paste the three images
x_positions = [
spacing,
spacing * 2 + page_width,
spacing * 3 + page_width * 2
]
for img, x_pos in zip([sans_image, serif_image, mono_image], x_positions):
composite.paste(img, (x_pos, label_height + spacing))
# Add labels
draw = ImageDraw.Draw(composite)
# Try to use a nice font, fallback to default if not available
try:
label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except:
label_font = ImageFont.load_default()
labels = ["Sans-Serif", "Serif", "Monospace"]
for label, x_pos in zip(labels, x_positions):
# Calculate text position to center it
bbox = draw.textbbox((0, 0), label, font=label_font)
text_width = bbox[2] - bbox[0]
text_x = x_pos + (page_width - text_width) // 2
draw.text((text_x, 5), label, fill='#333333', font=label_font)
# Save the image
output_path = "docs/images/font_family_switching.png"
composite.save(output_path, quality=95)
print(f"\n✓ Saved demo image to: {output_path}")
print(f" Image size: {total_width}x{total_height}")
return output_path
def create_single_vertical_comparison():
"""Create a vertical comparison that's better for README"""
# Page size for each panel
page_width = 700
page_height = 280
# Create demo content
print("\nCreating vertical comparison for README...")
blocks = create_demo_content()
# Render with each font family
print(" Rendering Sans...")
sans_image = render_with_font_family(
blocks, (page_width, page_height), BundledFont.SANS, "Sans"
)
print(" Rendering Serif...")
serif_image = render_with_font_family(
blocks, (page_width, page_height), BundledFont.SERIF, "Serif"
)
print(" Rendering Monospace...")
mono_image = render_with_font_family(
blocks, (page_width, page_height), BundledFont.MONOSPACE, "Monospace"
)
# Create a composite image stacked vertically
spacing = 15
label_width = 120
total_width = page_width + label_width + spacing * 2
total_height = page_height * 3 + spacing * 4
composite = Image.new('RGB', (total_width, total_height), color='#ffffff')
# Add a subtle border
draw = ImageDraw.Draw(composite)
draw.rectangle([(0, 0), (total_width-1, total_height-1)], outline='#e0e0e0', width=1)
# Paste the three images vertically
y_positions = [
spacing,
spacing * 2 + page_height,
spacing * 3 + page_height * 2
]
images_data = [
(sans_image, "Sans-Serif", "#4A90E2"),
(serif_image, "Serif", "#E94B3C"),
(mono_image, "Monospace", "#50C878")
]
# Try to use a nice font
try:
label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except:
label_font = ImageFont.load_default()
small_font = ImageFont.load_default()
for (img, label, color), y_pos in zip(images_data, y_positions):
# Paste the page image
composite.paste(img, (label_width + spacing, y_pos))
# Draw label background
draw.rectangle(
[(spacing, y_pos + 10), (label_width, y_pos + 40)],
fill=color
)
# Draw label text
draw.text(
(spacing + 10, y_pos + 17),
label,
fill='#ffffff',
font=label_font
)
# Draw font description
descriptions = {
"Sans-Serif": "Clean & Modern",
"Serif": "Classic & Formal",
"Monospace": "Code & Technical"
}
draw.text(
(spacing + 5, y_pos + 50),
descriptions[label],
fill='#666666',
font=small_font
)
# Save the image
output_path = "docs/images/font_family_switching_vertical.png"
composite.save(output_path, quality=95)
print(f" ✓ Saved: {output_path}")
print(f" Size: {total_width}x{total_height}")
return output_path
if __name__ == "__main__":
print("=" * 70)
print("Generating README Demo Images")
print("=" * 70)
# Create both versions
horizontal_path = create_comparison_image()
vertical_path = create_single_vertical_comparison()
print("\n" + "=" * 70)
print("Demo images generated successfully!")
print("=" * 70)
print(f"\nHorizontal comparison: {horizontal_path}")
print(f"Vertical comparison: {vertical_path}")
print("\nRecommended for README: vertical version")
print("\nMarkdown snippet:")
print("```markdown")
print("![Font Family Switching](docs/images/font_family_switching_vertical.png)")
print("```")
print()

View File

@ -21,6 +21,7 @@ from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Text from pyWebLayout.concrete.text import Text
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Font from pyWebLayout.style import Font
from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
@ -181,32 +182,50 @@ class ChapterNavigator:
return self.chapters[0] if self.chapters else None return self.chapters[0] if self.chapters else None
class FontScaler: class FontFamilyOverride:
""" """
Handles font scaling operations for ereader font size adjustments. Manages font family preferences for ereader rendering.
Applies scaling at layout/render time while preserving original font objects. Allows dynamic font family switching without modifying source blocks.
""" """
@staticmethod def __init__(self, preferred_family: Optional[BundledFont] = None):
def scale_font(font: Font, scale_factor: float) -> Font:
""" """
Create a scaled version of a font for layout calculations. Initialize font family override.
Args:
preferred_family: Preferred bundled font family (None = use original fonts)
"""
self.preferred_family = preferred_family
def override_font(self, font: Font) -> Font:
"""
Create a new font with the preferred family while preserving other attributes.
Args: Args:
font: Original font object font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
Returns: Returns:
New Font object with scaled size Font with overridden family, or original if no override is set
""" """
if scale_factor == 1.0: if self.preferred_family is None:
return font return font
scaled_size = max(1, int(font.font_size * scale_factor)) # Get the appropriate font path for the preferred family
# preserving the original font's weight and style
new_font_path = get_bundled_font_path(
family=self.preferred_family,
weight=font.weight,
style=font.style
)
# If we couldn't find a matching font, fall back to original
if new_font_path is None:
return font
# Create a new font with the overridden path
return Font( return Font(
font_path=font._font_path, font_path=new_font_path,
font_size=scaled_size, font_size=font.font_size,
colour=font.colour, colour=font.colour,
weight=font.weight, weight=font.weight,
style=font.style, style=font.style,
@ -216,6 +235,49 @@ class FontScaler:
min_hyphenation_width=font.min_hyphenation_width min_hyphenation_width=font.min_hyphenation_width
) )
class FontScaler:
"""
Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects.
"""
@staticmethod
def scale_font(font: Font, scale_factor: float, family_override: Optional[FontFamilyOverride] = None) -> Font:
"""
Create a scaled version of a font for layout calculations.
Args:
font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
family_override: Optional font family override
Returns:
New Font object with scaled size and optional family override
"""
# Apply family override first if specified
working_font = font
if family_override is not None:
working_font = family_override.override_font(font)
# Then apply scaling
if scale_factor == 1.0:
return working_font
scaled_size = max(1, int(working_font.font_size * scale_factor))
return Font(
font_path=working_font._font_path,
font_size=scaled_size,
colour=working_font.colour,
weight=working_font.weight,
style=working_font.style,
decoration=working_font.decoration,
background=working_font.background,
language=working_font.language,
min_hyphenation_width=working_font.min_hyphenation_width
)
@staticmethod @staticmethod
def scale_word_spacing(spacing: Tuple[int, int], def scale_word_spacing(spacing: Tuple[int, int],
scale_factor: float) -> Tuple[int, int]: scale_factor: float) -> Tuple[int, int]:
@ -242,12 +304,14 @@ class BidirectionalLayouter:
page_size: Tuple[int, page_size: Tuple[int,
int] = (800, int] = (800,
600), 600),
alignment_override=None): alignment_override=None,
font_family_override: Optional[FontFamilyOverride] = None):
self.blocks = blocks self.blocks = blocks
self.page_style = page_style self.page_style = page_style
self.page_size = page_size self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks) self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override self.alignment_override = alignment_override
self.font_family_override = font_family_override
def render_page_forward(self, position: RenderingPosition, def render_page_forward(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
@ -401,14 +465,15 @@ class BidirectionalLayouter:
return final_page, final_start return final_page, final_start
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block: def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""Apply font scaling to all fonts in a block""" """Apply font scaling and font family override to all fonts in a block"""
if font_scale == 1.0: # Check if we need to do any transformation
if font_scale == 1.0 and self.font_family_override is None:
return block return block
# This is a simplified implementation # This is a simplified implementation
# In practice, we'd need to handle each block type appropriately # In practice, we'd need to handle each block type appropriately
if isinstance(block, (Paragraph, Heading)): if isinstance(block, (Paragraph, Heading)):
scaled_block_style = FontScaler.scale_font(block.style, font_scale) scaled_block_style = FontScaler.scale_font(block.style, font_scale, self.font_family_override)
if isinstance(block, Heading): if isinstance(block, Heading):
scaled_block = Heading(block.level, scaled_block_style) scaled_block = Heading(block.level, scaled_block_style)
else: else:
@ -419,7 +484,7 @@ class BidirectionalLayouter:
if isinstance(word, Word): if isinstance(word, Word):
scaled_word = Word( scaled_word = Word(
word.text, FontScaler.scale_font( word.text, FontScaler.scale_font(
word.style, font_scale)) word.style, font_scale, self.font_family_override))
scaled_block.add_word(scaled_word) scaled_block.add_word(scaled_word)
return scaled_block return scaled_block

View File

@ -17,6 +17,7 @@ from pyWebLayout.abstract.block import Block, HeadingLevel, Image, BlockType
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.image import RenderableImage from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import BundledFont
from pyWebLayout.layout.document_layouter import image_layouter from pyWebLayout.layout.document_layouter import image_layouter
@ -154,6 +155,7 @@ class EreaderLayoutManager:
Features: Features:
- Sub-second page rendering with intelligent buffering - Sub-second page rendering with intelligent buffering
- Font scaling support - Font scaling support
- Dynamic font family switching (Sans, Serif, Monospace)
- Chapter navigation - Chapter navigation
- Bookmark management - Bookmark management
- Position persistence - Position persistence
@ -550,6 +552,43 @@ class EreaderLayoutManager:
"""Get the current font scale""" """Get the current font scale"""
return self.font_scale return self.font_scale
def set_font_family(self, family: Optional[BundledFont]) -> Page:
"""
Change the font family and re-render current page.
Switches all text in the document to use the specified bundled font family
while preserving font weights, styles, sizes, and other attributes.
Clears page history and cache since font changes invalidate all cached positions.
Args:
family: Bundled font family to use (SANS, SERIF, MONOSPACE, or None for original fonts)
Returns:
Re-rendered page with new font family
Example:
>>> from pyWebLayout.style.fonts import BundledFont
>>> manager.set_font_family(BundledFont.SERIF) # Switch to serif
>>> manager.set_font_family(BundledFont.SANS) # Switch to sans
>>> manager.set_font_family(None) # Restore original fonts
"""
# Update the renderer's font family
self.renderer.set_font_family(family)
# Clear history since font changes invalidate all cached positions
self._clear_history()
return self.get_current_page()
def get_font_family(self) -> Optional[BundledFont]:
"""
Get the current font family override.
Returns:
Current font family (SANS, SERIF, MONOSPACE) or None if using original fonts
"""
return self.renderer.get_font_family()
def increase_line_spacing(self, amount: int = 2) -> Page: def increase_line_spacing(self, amount: int = 2) -> Page:
""" """
Increase line spacing and re-render current page. Increase line spacing and re-render current page.
@ -787,6 +826,7 @@ class EreaderLayoutManager:
Dictionary with position details Dictionary with position details
""" """
current_chapter = self.get_current_chapter() current_chapter = self.get_current_chapter()
font_family = self.get_font_family()
return { return {
'position': self.current_position.to_dict(), 'position': self.current_position.to_dict(),
@ -799,6 +839,7 @@ class EreaderLayoutManager:
}, },
'progress': self.get_reading_progress(), 'progress': self.get_reading_progress(),
'font_scale': self.font_scale, 'font_scale': self.font_scale,
'font_family': font_family.value if font_family else None,
'page_size': self.page_size 'page_size': self.page_size
} }

View File

@ -12,31 +12,36 @@ from concurrent.futures import ProcessPoolExecutor, Future
import threading import threading
import pickle import pickle
from .ereader_layout import RenderingPosition, BidirectionalLayouter from .ereader_layout import RenderingPosition, BidirectionalLayouter, FontFamilyOverride
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.abstract.block import Block from pyWebLayout.abstract.block import Block
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import BundledFont
def _render_page_worker(args: Tuple[List[Block], def _render_page_worker(args: Tuple[List[Block],
PageStyle, PageStyle,
RenderingPosition, RenderingPosition,
float, float,
bool]) -> Tuple[RenderingPosition, bool,
Optional[BundledFont]]) -> Tuple[RenderingPosition,
bytes, bytes,
RenderingPosition]: RenderingPosition]:
""" """
Worker function for multiprocess page rendering. Worker function for multiprocess page rendering.
Args: Args:
args: Tuple of (blocks, page_style, position, font_scale, is_backward) args: Tuple of (blocks, page_style, position, font_scale, is_backward, font_family)
Returns: Returns:
Tuple of (original_position, pickled_page, next_position) Tuple of (original_position, pickled_page, next_position)
""" """
blocks, page_style, position, font_scale, is_backward = args blocks, page_style, position, font_scale, is_backward, font_family = args
layouter = BidirectionalLayouter(blocks, page_style) # Create font family override if specified
font_family_override = FontFamilyOverride(font_family) if font_family else None
layouter = BidirectionalLayouter(blocks, page_style, font_family_override=font_family_override)
if is_backward: if is_backward:
page, next_pos = layouter.render_page_backward(position, font_scale) page, next_pos = layouter.render_page_backward(position, font_scale)
@ -85,12 +90,14 @@ class PageBuffer:
self.blocks: Optional[List[Block]] = None self.blocks: Optional[List[Block]] = None
self.page_style: Optional[PageStyle] = None self.page_style: Optional[PageStyle] = None
self.current_font_scale: float = 1.0 self.current_font_scale: float = 1.0
self.current_font_family: Optional[BundledFont] = None
def initialize( def initialize(
self, self,
blocks: List[Block], blocks: List[Block],
page_style: PageStyle, page_style: PageStyle,
font_scale: float = 1.0): font_scale: float = 1.0,
font_family: Optional[BundledFont] = None):
""" """
Initialize the buffer with document blocks and page style. Initialize the buffer with document blocks and page style.
@ -98,10 +105,12 @@ class PageBuffer:
blocks: Document blocks to render blocks: Document blocks to render
page_style: Page styling configuration page_style: Page styling configuration
font_scale: Current font scaling factor font_scale: Current font scaling factor
font_family: Optional font family override
""" """
self.blocks = blocks self.blocks = blocks
self.page_style = page_style self.page_style = page_style
self.current_font_scale = font_scale self.current_font_scale = font_scale
self.current_font_family = font_family
# Start the process pool # Start the process pool
if self.executor is None: if self.executor is None:
@ -207,7 +216,8 @@ class PageBuffer:
self.page_style, self.page_style,
current_pos, current_pos,
self.current_font_scale, self.current_font_scale,
False) False,
self.current_font_family)
future = self.executor.submit(_render_page_worker, args) future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future self.pending_renders[current_pos] = future
@ -234,7 +244,8 @@ class PageBuffer:
self.page_style, self.page_style,
current_pos, current_pos,
self.current_font_scale, self.current_font_scale,
True) True,
self.current_font_family)
future = self.executor.submit(_render_page_worker, args) future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future self.pending_renders[current_pos] = future
@ -296,6 +307,17 @@ class PageBuffer:
self.current_font_scale = font_scale self.current_font_scale = font_scale
self.invalidate_all() self.invalidate_all()
def set_font_family(self, font_family: Optional[BundledFont]):
"""
Update font family and invalidate cache.
Args:
font_family: New font family (None = use original fonts)
"""
if font_family != self.current_font_family:
self.current_font_family = font_family
self.invalidate_all()
def get_cache_stats(self) -> Dict[str, Any]: def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics for debugging/monitoring""" """Get cache statistics for debugging/monitoring"""
return { return {
@ -304,7 +326,8 @@ class PageBuffer:
'pending_renders': len(self.pending_renders), 'pending_renders': len(self.pending_renders),
'position_mappings': len(self.position_map), 'position_mappings': len(self.position_map),
'reverse_position_mappings': len(self.reverse_position_map), 'reverse_position_mappings': len(self.reverse_position_map),
'current_font_scale': self.current_font_scale 'current_font_scale': self.current_font_scale,
'current_font_family': self.current_font_family.value if self.current_font_family else None
} }
def shutdown(self): def shutdown(self):
@ -338,7 +361,8 @@ class BufferedPageRenderer:
buffer_size: int = 5, buffer_size: int = 5,
page_size: Tuple[int, page_size: Tuple[int,
int] = (800, int] = (800,
600)): 600),
font_family: Optional[BundledFont] = None):
""" """
Initialize the buffered renderer. Initialize the buffered renderer.
@ -347,13 +371,21 @@ class BufferedPageRenderer:
page_style: Page styling configuration page_style: Page styling configuration
buffer_size: Number of pages to cache in each direction buffer_size: Number of pages to cache in each direction
page_size: Page size (width, height) in pixels page_size: Page size (width, height) in pixels
font_family: Optional font family override
""" """
self.layouter = BidirectionalLayouter(blocks, page_style, page_size) # Create font family override if specified
font_family_override = FontFamilyOverride(font_family) if font_family else None
self.layouter = BidirectionalLayouter(blocks, page_style, page_size, font_family_override=font_family_override)
self.buffer = PageBuffer(buffer_size) self.buffer = PageBuffer(buffer_size)
self.buffer.initialize(blocks, page_style) self.buffer.initialize(blocks, page_style, font_family=font_family)
self.page_size = page_size
self.blocks = blocks
self.page_style = page_style
self.current_position = RenderingPosition() self.current_position = RenderingPosition()
self.font_scale = 1.0 self.font_scale = 1.0
self.font_family = font_family
def render_page(self, position: RenderingPosition, def render_page(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
@ -453,6 +485,32 @@ class BufferedPageRenderer:
return page, start_pos return page, start_pos
def set_font_family(self, font_family: Optional[BundledFont]):
"""
Change the font family and invalidate cache.
Args:
font_family: New font family (None = use original fonts)
"""
if font_family != self.font_family:
self.font_family = font_family
# Update buffer
self.buffer.set_font_family(font_family)
# Recreate layouter with new font family override
font_family_override = FontFamilyOverride(font_family) if font_family else None
self.layouter = BidirectionalLayouter(
self.blocks,
self.page_style,
self.page_size,
font_family_override=font_family_override
)
def get_font_family(self) -> Optional[BundledFont]:
"""Get the current font family override"""
return self.font_family
def get_cache_stats(self) -> Dict[str, Any]: def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics""" """Get cache statistics"""
return self.buffer.get_cache_stats() return self.buffer.get_cache_stats()