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
+ Font 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("![Font Family Switching](docs/images/font_family_switching_vertical.png)") + 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()