diff --git a/README.md b/README.md
index f5b7485..876c5c5 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ PyWebLayout is a Python library for HTML-like layout and rendering to paginated
### Text and HTML Support
- š **HTML Parsing** - Parse HTML content into structured document blocks
- š¤ **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
- š **Rich Content** - Headings, paragraphs, bold, italic, and more
- š **Table Rendering** - Full HTML table support with headers, borders, and styling
@@ -138,6 +139,13 @@ The library supports various page layouts and configurations:
All 14 form field types with validation
+
+
+ š Dynamic Font Family Switching
+ 
+ Switch between Sans, Serif, and Monospace fonts instantly
+ |
+
## 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
- **[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
+- **[08_bundled_fonts_demo.py](examples/08_bundled_fonts_demo.py)** - Using the bundled DejaVu font families
### š 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))
- **[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))
+- **[11_font_family_switching_demo.py](examples/11_font_family_switching_demo.py)** - š Dynamic font switching in ereader
Run any example:
```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.
+## 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
- **[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
- **[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
diff --git a/docs/images/font_family_switching.png b/docs/images/font_family_switching.png
new file mode 100644
index 0000000..4cd23e4
Binary files /dev/null and b/docs/images/font_family_switching.png differ
diff --git a/docs/images/font_family_switching_vertical.png b/docs/images/font_family_switching_vertical.png
new file mode 100644
index 0000000..6b271b4
Binary files /dev/null and b/docs/images/font_family_switching_vertical.png differ
diff --git a/examples/11_font_family_switching_demo.py b/examples/11_font_family_switching_demo.py
new file mode 100644
index 0000000..75e1e1e
--- /dev/null
+++ b/examples/11_font_family_switching_demo.py
@@ -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()
diff --git a/examples/generate_readme_font_demo.py b/examples/generate_readme_font_demo.py
new file mode 100644
index 0000000..d0aa9ba
--- /dev/null
+++ b/examples/generate_readme_font_demo.py
@@ -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("")
+ print("```")
+ print()
diff --git a/pyWebLayout/layout/ereader_layout.py b/pyWebLayout/layout/ereader_layout.py
index 477ea17..3699e3b 100644
--- a/pyWebLayout/layout/ereader_layout.py
+++ b/pyWebLayout/layout/ereader_layout.py
@@ -21,6 +21,7 @@ from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.page_style import PageStyle
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
@@ -181,32 +182,50 @@ class ChapterNavigator:
return self.chapters[0] if self.chapters else None
-class FontScaler:
+class FontFamilyOverride:
"""
- Handles font scaling operations for ereader font size adjustments.
- Applies scaling at layout/render time while preserving original font objects.
+ Manages font family preferences for ereader rendering.
+ Allows dynamic font family switching without modifying source blocks.
"""
- @staticmethod
- def scale_font(font: Font, scale_factor: float) -> Font:
+ def __init__(self, preferred_family: Optional[BundledFont] = None):
"""
- 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:
font: Original font object
- scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
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
- 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(
- font_path=font._font_path,
- font_size=scaled_size,
+ font_path=new_font_path,
+ font_size=font.font_size,
colour=font.colour,
weight=font.weight,
style=font.style,
@@ -216,6 +235,49 @@ class FontScaler:
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
def scale_word_spacing(spacing: Tuple[int, int],
scale_factor: float) -> Tuple[int, int]:
@@ -242,12 +304,14 @@ class BidirectionalLayouter:
page_size: Tuple[int,
int] = (800,
600),
- alignment_override=None):
+ alignment_override=None,
+ font_family_override: Optional[FontFamilyOverride] = None):
self.blocks = blocks
self.page_style = page_style
self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override
+ self.font_family_override = font_family_override
def render_page_forward(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
@@ -401,14 +465,15 @@ class BidirectionalLayouter:
return final_page, final_start
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
- """Apply font scaling to all fonts in a block"""
- if font_scale == 1.0:
+ """Apply font scaling and font family override to all fonts in a block"""
+ # Check if we need to do any transformation
+ if font_scale == 1.0 and self.font_family_override is None:
return block
# This is a simplified implementation
# In practice, we'd need to handle each block type appropriately
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):
scaled_block = Heading(block.level, scaled_block_style)
else:
@@ -419,7 +484,7 @@ class BidirectionalLayouter:
if isinstance(word, Word):
scaled_word = Word(
word.text, FontScaler.scale_font(
- word.style, font_scale))
+ word.style, font_scale, self.font_family_override))
scaled_block.add_word(scaled_word)
return scaled_block
diff --git a/pyWebLayout/layout/ereader_manager.py b/pyWebLayout/layout/ereader_manager.py
index 316431b..8c25e1b 100644
--- a/pyWebLayout/layout/ereader_manager.py
+++ b/pyWebLayout/layout/ereader_manager.py
@@ -17,6 +17,7 @@ from pyWebLayout.abstract.block import Block, HeadingLevel, Image, BlockType
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.style.page_style import PageStyle
+from pyWebLayout.style.fonts import BundledFont
from pyWebLayout.layout.document_layouter import image_layouter
@@ -154,6 +155,7 @@ class EreaderLayoutManager:
Features:
- Sub-second page rendering with intelligent buffering
- Font scaling support
+ - Dynamic font family switching (Sans, Serif, Monospace)
- Chapter navigation
- Bookmark management
- Position persistence
@@ -550,6 +552,43 @@ class EreaderLayoutManager:
"""Get the current 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:
"""
Increase line spacing and re-render current page.
@@ -787,6 +826,7 @@ class EreaderLayoutManager:
Dictionary with position details
"""
current_chapter = self.get_current_chapter()
+ font_family = self.get_font_family()
return {
'position': self.current_position.to_dict(),
@@ -799,6 +839,7 @@ class EreaderLayoutManager:
},
'progress': self.get_reading_progress(),
'font_scale': self.font_scale,
+ 'font_family': font_family.value if font_family else None,
'page_size': self.page_size
}
diff --git a/pyWebLayout/layout/page_buffer.py b/pyWebLayout/layout/page_buffer.py
index c5b69f2..0b95672 100644
--- a/pyWebLayout/layout/page_buffer.py
+++ b/pyWebLayout/layout/page_buffer.py
@@ -12,31 +12,36 @@ from concurrent.futures import ProcessPoolExecutor, Future
import threading
import pickle
-from .ereader_layout import RenderingPosition, BidirectionalLayouter
+from .ereader_layout import RenderingPosition, BidirectionalLayouter, FontFamilyOverride
from pyWebLayout.concrete.page import Page
from pyWebLayout.abstract.block import Block
from pyWebLayout.style.page_style import PageStyle
+from pyWebLayout.style.fonts import BundledFont
def _render_page_worker(args: Tuple[List[Block],
PageStyle,
RenderingPosition,
float,
- bool]) -> Tuple[RenderingPosition,
+ bool,
+ Optional[BundledFont]]) -> Tuple[RenderingPosition,
bytes,
RenderingPosition]:
"""
Worker function for multiprocess page rendering.
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:
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:
page, next_pos = layouter.render_page_backward(position, font_scale)
@@ -85,12 +90,14 @@ class PageBuffer:
self.blocks: Optional[List[Block]] = None
self.page_style: Optional[PageStyle] = None
self.current_font_scale: float = 1.0
+ self.current_font_family: Optional[BundledFont] = None
def initialize(
self,
blocks: List[Block],
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.
@@ -98,10 +105,12 @@ class PageBuffer:
blocks: Document blocks to render
page_style: Page styling configuration
font_scale: Current font scaling factor
+ font_family: Optional font family override
"""
self.blocks = blocks
self.page_style = page_style
self.current_font_scale = font_scale
+ self.current_font_family = font_family
# Start the process pool
if self.executor is None:
@@ -207,7 +216,8 @@ class PageBuffer:
self.page_style,
current_pos,
self.current_font_scale,
- False)
+ False,
+ self.current_font_family)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
@@ -234,7 +244,8 @@ class PageBuffer:
self.page_style,
current_pos,
self.current_font_scale,
- True)
+ True,
+ self.current_font_family)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
@@ -296,6 +307,17 @@ class PageBuffer:
self.current_font_scale = font_scale
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]:
"""Get cache statistics for debugging/monitoring"""
return {
@@ -304,7 +326,8 @@ class PageBuffer:
'pending_renders': len(self.pending_renders),
'position_mappings': len(self.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):
@@ -338,7 +361,8 @@ class BufferedPageRenderer:
buffer_size: int = 5,
page_size: Tuple[int,
int] = (800,
- 600)):
+ 600),
+ font_family: Optional[BundledFont] = None):
"""
Initialize the buffered renderer.
@@ -347,13 +371,21 @@ class BufferedPageRenderer:
page_style: Page styling configuration
buffer_size: Number of pages to cache in each direction
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.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.font_scale = 1.0
+ self.font_family = font_family
def render_page(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
@@ -453,6 +485,32 @@ class BufferedPageRenderer:
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]:
"""Get cache statistics"""
return self.buffer.get_cache_stats()