diff --git a/demo_viewport_system.py b/demo_viewport_system.py new file mode 100644 index 0000000..dba5ffe --- /dev/null +++ b/demo_viewport_system.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Demo script showing the viewport system in action. + +This demonstrates how the viewport provides a movable window into large content, +enabling efficient scrolling without rendering the entire content at once. +""" + +import os +from PIL import Image +from pyWebLayout.concrete import ( + Viewport, ScrollablePageContent, Text, Box, RenderableImage +) +from pyWebLayout.style.fonts import Font, FontWeight +from pyWebLayout.style.layout import Alignment + + +def create_large_document_content(): + """Create a large document to demonstrate viewport scrolling""" + + # Create scrollable content container + content = ScrollablePageContent(content_width=800, initial_height=100) + + # Add a title + title_font = Font(font_size=24, weight=FontWeight.BOLD) + title = Text("Large Document Demo", title_font) + content.add_child(title) + + # Add spacing + content.add_child(Box((0, 0), (1, 20))) + + # Add many paragraphs to create a long document + paragraph_font = Font(font_size=14) + + for i in range(50): + # Section header + section_font = Font(font_size=18, weight=FontWeight.BOLD) + header = Text(f"Section {i+1}", section_font) + content.add_child(header) + + # Add some spacing + content.add_child(Box((0, 0), (1, 10))) + + # Add paragraph content + paragraphs = [ + f"This is paragraph {i+1}, line 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + f"This is paragraph {i+1}, line 2. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + f"This is paragraph {i+1}, line 3. Ut enim ad minim veniam, quis nostrud exercitation ullamco.", + f"This is paragraph {i+1}, line 4. Duis aute irure dolor in reprehenderit in voluptate velit esse.", + f"This is paragraph {i+1}, line 5. Excepteur sint occaecat cupidatat non proident, sunt in culpa." + ] + + for para_text in paragraphs: + para = Text(para_text, paragraph_font) + content.add_child(para) + content.add_child(Box((0, 0), (1, 5))) # Line spacing + + # Add section spacing + content.add_child(Box((0, 0), (1, 15))) + + return content + + +def demo_viewport_rendering(): + """Demonstrate viewport rendering at different scroll positions""" + + print("Creating large document content...") + content = create_large_document_content() + + print(f"Content size: {content.get_content_height()} pixels tall") + + # Create viewport + viewport = Viewport(viewport_size=(800, 600), background_color=(255, 255, 255)) + + # Add content to viewport + viewport.add_content(content) + + print(f"Viewport content size: {viewport.content_size}") + print(f"Max scroll Y: {viewport.max_scroll_y}") + + # Create output directory + os.makedirs("output/viewport_demo", exist_ok=True) + + # Render viewport at different scroll positions + scroll_positions = [ + (0, "top"), + (viewport.max_scroll_y // 4, "quarter"), + (viewport.max_scroll_y // 2, "middle"), + (viewport.max_scroll_y * 3 // 4, "three_quarters"), + (viewport.max_scroll_y, "bottom") + ] + + for scroll_y, label in scroll_positions: + print(f"Rendering viewport at scroll position {scroll_y} ({label})...") + + # Scroll to position + viewport.scroll_to(0, scroll_y) + + # Get scroll info + scroll_info = viewport.get_scroll_info() + print(f" Scroll progress: {scroll_info['scroll_progress_y']:.2%}") + print(f" Visible elements: {len(viewport.get_visible_elements())}") + + # Render viewport + viewport_img = viewport.render() + + # Save image + output_path = f"output/viewport_demo/viewport_{label}.png" + viewport_img.save(output_path) + print(f" Saved: {output_path}") + + print("\nViewport demo complete!") + return viewport + + +def demo_hit_testing(): + """Demonstrate hit testing in the viewport""" + + print("\nTesting hit detection...") + + # Create simple content for hit testing + content = ScrollablePageContent(content_width=800, initial_height=100) + + # Add clickable elements + for i in range(10): + text = Text(f"Clickable text item {i}", Font(font_size=16)) + content.add_child(text) + content.add_child(Box((0, 0), (1, 20))) # Spacing + + # Create viewport + viewport = Viewport(viewport_size=(800, 300)) + viewport.add_content(content) + + # Test hit detection at different scroll positions + test_points = [(100, 50), (200, 100), (300, 150)] + + for scroll_y in [0, 100, 200]: + viewport.scroll_to(0, scroll_y) + print(f"\nAt scroll position {scroll_y}:") + + for point in test_points: + hit_element = viewport.hit_test(point) + if hit_element: + element_text = getattr(hit_element, '_text', 'Unknown element') + print(f" Point {point}: Hit '{element_text}'") + else: + print(f" Point {point}: No element") + + +def demo_scroll_methods(): + """Demonstrate different scrolling methods""" + + print("\nTesting scroll methods...") + + # Create content + content = ScrollablePageContent(content_width=800, initial_height=100) + + for i in range(20): + text = Text(f"Line {i+1}: This is some sample text for scrolling demo", Font(font_size=14)) + content.add_child(text) + content.add_child(Box((0, 0), (1, 5))) + + # Create viewport + viewport = Viewport(viewport_size=(800, 200)) + viewport.add_content(content) + + print(f"Content height: {viewport.content_size[1]}") + print(f"Viewport height: {viewport.viewport_size[1]}") + print(f"Max scroll Y: {viewport.max_scroll_y}") + + # Test different scroll methods + print("\nTesting scroll methods:") + + # Scroll by lines + print("Scrolling down 5 lines...") + for i in range(5): + viewport.scroll_line_down(20) + print(f" After line {i+1}: offset = {viewport.viewport_offset}") + + # Scroll by pages + print("Scrolling down 1 page...") + viewport.scroll_page_down() + print(f" After page down: offset = {viewport.viewport_offset}") + + # Scroll to bottom + print("Scrolling to bottom...") + viewport.scroll_to_bottom() + print(f" At bottom: offset = {viewport.viewport_offset}") + + # Scroll to top + print("Scrolling to top...") + viewport.scroll_to_top() + print(f" At top: offset = {viewport.viewport_offset}") + + +def main(): + """Run all viewport demos""" + print("=== Viewport System Demo ===") + + # Demo 1: Basic viewport rendering + viewport = demo_viewport_rendering() + + # Demo 2: Hit testing + demo_hit_testing() + + # Demo 3: Scroll methods + demo_scroll_methods() + + print("\n=== Demo Complete ===") + print("Check the output/viewport_demo/ directory for rendered images.") + + # Show final scroll info + scroll_info = viewport.get_scroll_info() + print(f"\nFinal viewport state:") + print(f" Content size: {scroll_info['content_size']}") + print(f" Viewport size: {scroll_info['viewport_size']}") + print(f" Current offset: {scroll_info['offset']}") + print(f" Scroll progress: {scroll_info['scroll_progress_y']:.1%}") + print(f" Can scroll up: {scroll_info['can_scroll_up']}") + print(f" Can scroll down: {scroll_info['can_scroll_down']}") + + +if __name__ == "__main__": + main() diff --git a/html_browser_with_viewport.py b/html_browser_with_viewport.py new file mode 100644 index 0000000..9ecc531 --- /dev/null +++ b/html_browser_with_viewport.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +""" +Enhanced HTML Browser using pyWebLayout with Viewport System + +This browser uses the new viewport system to enable efficient scrolling +within HTML pages, only rendering the visible portion of large documents. +""" + +import re +import tkinter as tk +from tkinter import ttk, messagebox, filedialog, simpledialog +from PIL import Image, ImageTk, ImageDraw +from typing import Dict, List, Optional, Tuple, Any +import webbrowser +import os +from urllib.parse import urljoin, urlparse +import requests +from io import BytesIO +import pyperclip + +# Import pyWebLayout components including the new viewport system +from pyWebLayout.concrete import ( + Page, Container, Box, Text, RenderableImage, + RenderableLink, RenderableButton, RenderableForm, RenderableFormField, + Viewport, ScrollablePageContent +) +from pyWebLayout.abstract.functional import ( + Link, Button, Form, FormField, LinkType, FormFieldType +) +from pyWebLayout.abstract.block import Paragraph +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration +from pyWebLayout.style.layout import Alignment +from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphLayoutResult +from pyWebLayout.io.readers.html_extraction import parse_html_string + + +class HTMLViewportAdapter: + """Adapter to convert HTML to viewport using the proper HTML extraction system""" + + def __init__(self): + pass + + def parse_html_string(self, html_content: str, base_url: str = "", viewport_size: Tuple[int, int] = (800, 600)) -> Viewport: + """Parse HTML string and return a Viewport object with scrollable content using the proper parser""" + # Use the proper HTML extraction system + base_font = Font(font_size=14) + blocks = parse_html_string(html_content, base_font) + + # Extract title + title_match = re.search(r'(.*?)', html_content, re.IGNORECASE) + title = title_match.group(1) if title_match else "Untitled" + + # Create scrollable content container + content = ScrollablePageContent(content_width=viewport_size[0] - 20, initial_height=100) + + # Convert abstract blocks to renderable objects using Page's conversion system + page = Page(size=(viewport_size[0], 10000)) # Large temporary page + + # Add blocks to page and let it handle the conversion + for i, block in enumerate(blocks): + renderable = page._convert_block_to_renderable(block) + if renderable: + content.add_child(renderable) + # Add spacing between blocks (but not after the last block) + if i < len(blocks) - 1: + content.add_child(Box((0, 0), (1, 8))) + + # Create viewport and add the content + viewport = Viewport(viewport_size=viewport_size, background_color=(255, 255, 255)) + viewport.add_content(content) + viewport.title = title + + return viewport + + def parse_html_file(self, file_path: str, viewport_size: Tuple[int, int] = (800, 600)) -> Viewport: + """Parse HTML file and return a Viewport object""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + html_content = f.read() + base_url = os.path.dirname(os.path.abspath(file_path)) + return self.parse_html_string(html_content, base_url, viewport_size) + except Exception as e: + # Create error viewport + content = ScrollablePageContent(content_width=viewport_size[0] - 20, initial_height=100) + error_text = Text(f"Error loading file: {str(e)}", Font(font_size=16, colour=(255, 0, 0))) + content.add_child(error_text) + + viewport = Viewport(viewport_size=viewport_size, background_color=(255, 255, 255)) + viewport.add_content(content) + viewport.title = "Error" + return viewport + + +class ViewportBrowserWindow: + """Enhanced browser window using Tkinter with Viewport support""" + + def __init__(self): + self.root = tk.Tk() + self.root.title("pyWebLayout HTML Browser with Viewport") + self.root.geometry("1000x800") + + self.current_viewport = None + self.history = [] + self.history_index = -1 + + # Scrolling parameters + self.scroll_speed = 20 # pixels per scroll + self.page_scroll_ratio = 0.9 # fraction of viewport height for page scroll + + # Text selection variables + self.selection_start = None + self.selection_end = None + self.is_selecting = False + self.selected_text = "" + self.text_elements = [] + self.selection_overlay = None + + self.setup_ui() + + def setup_ui(self): + """Setup the user interface""" + # Create main frame + main_frame = ttk.Frame(self.root) + main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Navigation frame + nav_frame = ttk.Frame(main_frame) + nav_frame.pack(fill=tk.X, pady=(0, 5)) + + # Navigation buttons + self.back_btn = ttk.Button(nav_frame, text="←", command=self.go_back, state=tk.DISABLED) + self.back_btn.pack(side=tk.LEFT, padx=(0, 5)) + + self.forward_btn = ttk.Button(nav_frame, text="→", command=self.go_forward, state=tk.DISABLED) + self.forward_btn.pack(side=tk.LEFT, padx=(0, 5)) + + self.refresh_btn = ttk.Button(nav_frame, text="⟳", command=self.refresh) + self.refresh_btn.pack(side=tk.LEFT, padx=(0, 10)) + + # Address bar + ttk.Label(nav_frame, text="URL:").pack(side=tk.LEFT) + self.url_var = tk.StringVar() + self.url_entry = ttk.Entry(nav_frame, textvariable=self.url_var, width=50) + self.url_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 5)) + self.url_entry.bind('', self.navigate_to_url) + + self.go_btn = ttk.Button(nav_frame, text="Go", command=self.navigate_to_url) + self.go_btn.pack(side=tk.LEFT, padx=(0, 10)) + + # File operations + self.open_btn = ttk.Button(nav_frame, text="Open File", command=self.open_file) + self.open_btn.pack(side=tk.LEFT) + + # Content frame with scrollbars and viewport controls + content_frame = ttk.Frame(main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + + # Create scrollbar frame + scroll_frame = ttk.Frame(content_frame) + scroll_frame.pack(side=tk.RIGHT, fill=tk.Y) + + # Vertical scrollbar + self.v_scrollbar = ttk.Scrollbar(scroll_frame, orient=tk.VERTICAL, command=self.on_scrollbar) + self.v_scrollbar.pack(fill=tk.Y) + + # Canvas for displaying viewport content + self.canvas = tk.Canvas(content_frame, bg='white') + self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Scroll info frame + info_frame = ttk.Frame(main_frame) + info_frame.pack(fill=tk.X, pady=(5, 0)) + + self.scroll_info_var = tk.StringVar(value="") + ttk.Label(info_frame, textvariable=self.scroll_info_var).pack(side=tk.LEFT) + + # Status bar + self.status_var = tk.StringVar(value="Ready") + status_bar = ttk.Label(main_frame, textvariable=self.status_var, relief=tk.SUNKEN) + status_bar.pack(fill=tk.X, pady=(5, 0)) + + # Bind events + self.canvas.bind('', self.on_click) + self.canvas.bind('', self.on_drag) + self.canvas.bind('', self.on_release) + self.canvas.bind('', self.on_mouse_move) + self.canvas.bind('', self.on_mouse_wheel) # Windows/Mac + self.canvas.bind('', self.on_mouse_wheel) # Linux scroll up + self.canvas.bind('', self.on_mouse_wheel) # Linux scroll down + + # Keyboard shortcuts + self.root.bind('', self.copy_selection) + self.root.bind('', self.select_all) + self.root.bind('', self.page_up) # Page Up + self.root.bind('', self.page_down) # Page Down + self.root.bind('', self.scroll_to_top) # Home + self.root.bind('', self.scroll_to_bottom) # End + self.root.bind('', lambda e: self.scroll_by_lines(-1)) + self.root.bind('', lambda e: self.scroll_by_lines(1)) + + # Context menu + self.setup_context_menu() + + # Make canvas focusable + self.canvas.config(highlightthickness=1) + self.canvas.focus_set() + + # Load default page + self.load_default_page() + + def setup_context_menu(self): + """Setup the right-click context menu""" + self.context_menu = tk.Menu(self.root, tearoff=0) + self.context_menu.add_command(label="Copy", command=self.copy_selection) + self.context_menu.add_command(label="Select All", command=self.select_all) + self.context_menu.add_separator() + self.context_menu.add_command(label="Scroll to Top", command=self.scroll_to_top) + self.context_menu.add_command(label="Scroll to Bottom", command=self.scroll_to_bottom) + + # Bind right-click to show context menu + self.canvas.bind('', self.show_context_menu) + + def show_context_menu(self, event): + """Show context menu at mouse position""" + try: + self.context_menu.tk_popup(event.x_root, event.y_root) + finally: + self.context_menu.grab_release() + + def on_scrollbar(self, *args): + """Handle scrollbar movement""" + if not self.current_viewport: + return + + action = args[0] + + if action == "moveto": + # Absolute position (0.0 to 1.0) + fraction = float(args[1]) + max_scroll = self.current_viewport.max_scroll_y + new_y = int(fraction * max_scroll) + self.current_viewport.scroll_to(0, new_y) + self.update_viewport_display() + + elif action in ["scroll", "step"]: + # Relative movement + direction = int(args[1]) + # Handle the units parameter properly - it might be a string "units" + if len(args) > 2: + try: + units = int(args[2]) + except ValueError: + # If args[2] is "units" string, default to 1 + units = 1 + else: + units = 1 + + if action == "scroll": + # Line-based scrolling + self.scroll_by_lines(direction * units) + elif action == "step": + # Page-based scrolling + self.scroll_by_pages(direction * units) + + def update_scrollbar(self): + """Update scrollbar position and size based on viewport state""" + if not self.current_viewport: + self.v_scrollbar.set(0, 1) + return + + scroll_info = self.current_viewport.get_scroll_info() + content_height = scroll_info['content_size'][1] + viewport_height = scroll_info['viewport_size'][1] + current_offset = scroll_info['offset'][1] + + if content_height <= viewport_height: + # No scrolling needed + self.v_scrollbar.set(0, 1) + else: + # Calculate scrollbar position and size + top_fraction = current_offset / content_height + bottom_fraction = (current_offset + viewport_height) / content_height + self.v_scrollbar.set(top_fraction, bottom_fraction) + + # Update scroll info display + progress = scroll_info['scroll_progress_y'] + self.scroll_info_var.set(f"Scroll: {progress:.1%} ({current_offset}/{content_height - viewport_height})") + + def on_mouse_wheel(self, event): + """Handle mouse wheel scrolling""" + if not self.current_viewport: + return + + # Determine scroll direction and amount + if event.num == 4 or event.delta > 0: + # Scroll up + self.scroll_by_lines(-3) + elif event.num == 5 or event.delta < 0: + # Scroll down + self.scroll_by_lines(3) + + def scroll_by_lines(self, lines: int): + """Scroll by a number of lines""" + if not self.current_viewport: + return + + delta_y = lines * self.scroll_speed + self.current_viewport.scroll_by(0, delta_y) + self.update_viewport_display() + + def scroll_by_pages(self, pages: int): + """Scroll by a number of pages""" + if not self.current_viewport: + return + + viewport_height = self.current_viewport.viewport_size[1] + delta_y = int(pages * viewport_height * self.page_scroll_ratio) + self.current_viewport.scroll_by(0, delta_y) + self.update_viewport_display() + + def page_up(self, event=None): + """Scroll up by one page""" + self.scroll_by_pages(-1) + + def page_down(self, event=None): + """Scroll down by one page""" + self.scroll_by_pages(1) + + def scroll_to_top(self, event=None): + """Scroll to the top of the document""" + if not self.current_viewport: + return + + self.current_viewport.scroll_to_top() + self.update_viewport_display() + + def scroll_to_bottom(self, event=None): + """Scroll to the bottom of the document""" + if not self.current_viewport: + return + + self.current_viewport.scroll_to_bottom() + self.update_viewport_display() + + def update_viewport_display(self): + """Update the canvas display with current viewport content""" + if not self.current_viewport: + return + + try: + # Render the current viewport + viewport_img = self.current_viewport.render() + + # Convert to PhotoImage for Tkinter + self.photo = ImageTk.PhotoImage(viewport_img) + + # Clear canvas and display image + self.canvas.delete("all") + self.canvas.create_image(0, 0, anchor=tk.NW, image=self.photo) + + # Update scrollbar + self.update_scrollbar() + + except Exception as e: + self.status_var.set(f"Error rendering viewport: {str(e)}") + + def on_drag(self, event): + """Handle mouse dragging for text selection""" + canvas_x = self.canvas.canvasx(event.x) + canvas_y = self.canvas.canvasy(event.y) + + if not self.is_selecting: + # Start selection + self.is_selecting = True + self.selection_start = (canvas_x, canvas_y) + self.selection_end = (canvas_x, canvas_y) + else: + # Update selection end + self.selection_end = (canvas_x, canvas_y) + + # Update visual selection + self.update_selection_visual() + + # Update status + self.status_var.set("Selecting text...") + + def on_release(self, event): + """Handle mouse release to complete text selection""" + if self.is_selecting: + canvas_x = self.canvas.canvasx(event.x) + canvas_y = self.canvas.canvasy(event.y) + self.selection_end = (canvas_x, canvas_y) + + # Extract selected text + self.extract_selected_text() + + # Update status + if self.selected_text: + self.status_var.set(f"Selected: {len(self.selected_text)} characters") + else: + self.status_var.set("No text selected") + self.clear_selection() + + def update_selection_visual(self): + """Update the visual representation of text selection""" + # Remove existing selection overlay + if self.selection_overlay: + self.canvas.delete(self.selection_overlay) + + if self.selection_start and self.selection_end: + # Create selection rectangle + x1, y1 = self.selection_start + x2, y2 = self.selection_end + + # Ensure proper coordinates (top-left to bottom-right) + left = min(x1, x2) + top = min(y1, y2) + right = max(x1, x2) + bottom = max(y1, y2) + + # Draw selection rectangle with transparency effect + self.selection_overlay = self.canvas.create_rectangle( + left, top, right, bottom, + fill='blue', stipple='gray50', outline='blue', width=1 + ) + + def extract_selected_text(self): + """Extract text that falls within the selection area""" + if not self.selection_start or not self.selection_end or not self.current_viewport: + self.selected_text = "" + return + + # Get selection bounds in viewport coordinates + x1, y1 = self.selection_start + x2, y2 = self.selection_end + left = min(x1, x2) + top = min(y1, y2) + right = max(x1, x2) + bottom = max(y1, y2) + + # Convert to content coordinates + viewport_offset = self.current_viewport.viewport_offset + content_left = left + viewport_offset[0] + content_top = top + viewport_offset[1] + content_right = right + viewport_offset[0] + content_bottom = bottom + viewport_offset[1] + + # Extract text elements in selection area + selected_elements = [] + visible_elements = self.current_viewport.get_visible_elements() + + for element, visible_origin, visible_size, clip_info in visible_elements: + # Check if element intersects with selection + elem_left = visible_origin[0] + elem_top = visible_origin[1] + elem_right = elem_left + visible_size[0] + elem_bottom = elem_top + visible_size[1] + + if (elem_left < right and elem_right > left and + elem_top < bottom and elem_bottom > top): + + # Extract text from element + if hasattr(element, '_text'): + text_content = element._text + if text_content.strip(): + selected_elements.append((text_content.strip(), elem_left, elem_top)) + + # Sort by position (top to bottom, left to right) + selected_elements.sort(key=lambda x: (x[2], x[1])) + + # Combine text + self.selected_text = " ".join([element[0] for element in selected_elements]) + + def copy_selection(self, event=None): + """Copy selected text to clipboard""" + if self.selected_text: + try: + pyperclip.copy(self.selected_text) + self.status_var.set(f"Copied {len(self.selected_text)} characters to clipboard") + except Exception as e: + self.status_var.set(f"Error copying to clipboard: {str(e)}") + else: + self.status_var.set("No text selected to copy") + + def select_all(self, event=None): + """Select all text on the page""" + if not self.current_viewport: + return + + # Set selection to entire viewport area + viewport_size = self.current_viewport.viewport_size + + self.selection_start = (0, 0) + self.selection_end = viewport_size + self.is_selecting = True + + # Extract all visible text + self.extract_selected_text() + + # Update visual + self.update_selection_visual() + + if self.selected_text: + self.status_var.set(f"Selected all visible text: {len(self.selected_text)} characters") + else: + self.status_var.set("No text found to select") + + def clear_selection(self): + """Clear the current text selection""" + self.selection_start = None + self.selection_end = None + self.is_selecting = False + self.selected_text = "" + + # Remove visual selection + if self.selection_overlay: + self.canvas.delete(self.selection_overlay) + self.selection_overlay = None + + self.status_var.set("Selection cleared") + + def load_default_page(self): + """Load a default welcome page""" + html_content = """ + + pyWebLayout Browser with Viewport - Welcome + +

Welcome to pyWebLayout Browser with Viewport System

+

This enhanced browser uses the new viewport system for efficient scrolling through large documents.

+ +

New Viewport Features:

+
    +
  • Efficient Rendering: Only visible content is rendered
  • +
  • Smooth Scrolling: Mouse wheel, keyboard, and scrollbar support
  • +
  • Large Document Support: Handle documents of any size
  • +
  • Memory Efficient: Low memory usage even for huge pages
  • +
+ +

Scrolling Controls:

+

Mouse Wheel: Scroll up and down

+

Page Up/Down: Scroll by viewport height

+

Home/End: Jump to top/bottom

+

Arrow Keys: Scroll line by line

+

Scrollbar: Click and drag for precise positioning

+ +

Text Selection:

+

Click and drag to select text, then use Ctrl+C to copy

+

Use Ctrl+A to select all visible text

+ +

Try scrolling with different methods!

+

This page demonstrates the viewport system. All the content above and below is efficiently managed.

+ +

Sample Content for Scrolling

+

Here's some additional content to demonstrate scrolling capabilities:

+ +

Lorem Ipsum

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

+ +

More Sample Text

+

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.

+

Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.

+ +

Even More Content

+

Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos.

+

Qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.

+

Consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.

+ +

Technical Details

+

The viewport system works by:

+
    +
  • Creating a large content container that can hold any amount of content
  • +
  • Providing a viewport window that shows only a portion of the content
  • +
  • Efficiently calculating which elements are visible
  • +
  • Rendering only the visible elements
  • +
  • Supporting smooth scrolling through the content
  • +
+ +

This allows handling of very large documents without performance issues.

+ +

Load Your Own Content

+

Use the "Open File" button to load local HTML files, or enter a URL in the address bar.

+ + + """ + + # Get current canvas size for viewport + self.root.update_idletasks() + canvas_width = max(800, self.canvas.winfo_width()) + canvas_height = max(600, self.canvas.winfo_height()) + + parser = HTMLViewportAdapter() + self.current_viewport = parser.parse_html_string(html_content, viewport_size=(canvas_width, canvas_height)) + + # Update window title + if hasattr(self.current_viewport, 'title'): + self.root.title(f"pyWebLayout Browser - {self.current_viewport.title}") + + self.update_viewport_display() + self.status_var.set("Welcome page loaded with viewport system") + + def navigate_to_url(self, event=None): + """Navigate to the URL in the address bar""" + url = self.url_var.get().strip() + if not url: + return + + self.status_var.set(f"Loading {url}...") + self.root.update() + + # Get current canvas size for viewport + self.root.update_idletasks() + canvas_width = max(800, self.canvas.winfo_width()) + canvas_height = max(600, self.canvas.winfo_height()) + + try: + parser = HTMLViewportAdapter() + + if url.startswith(('http://', 'https://')): + # Web URL + response = requests.get(url, timeout=10) + response.raise_for_status() + html_content = response.text + + self.current_viewport = parser.parse_html_string(html_content, url, (canvas_width, canvas_height)) + + elif os.path.isfile(url): + # Local file + self.current_viewport = parser.parse_html_file(url, (canvas_width, canvas_height)) + + else: + # Try to treat as a local file path + if not url.startswith('file://'): + url = 'file://' + os.path.abspath(url) + + file_path = url.replace('file://', '') + if os.path.isfile(file_path): + self.current_viewport = parser.parse_html_file(file_path, (canvas_width, canvas_height)) + else: + raise FileNotFoundError(f"File not found: {file_path}") + + # Update window title + if hasattr(self.current_viewport, 'title'): + self.root.title(f"pyWebLayout Browser - {self.current_viewport.title}") + + # Add to history + self.add_to_history(url) + self.update_viewport_display() + self.status_var.set(f"Loaded {url}") + + except Exception as e: + self.status_var.set(f"Error loading {url}: {str(e)}") + messagebox.showerror("Error", f"Failed to load {url}:\n{str(e)}") + + def open_file(self): + """Open a local HTML file""" + file_path = filedialog.askopenfilename( + title="Open HTML File", + filetypes=[("HTML files", "*.html *.htm"), ("All files", "*.*")] + ) + + if file_path: + self.url_var.set(file_path) + self.navigate_to_url() + + def on_click(self, event): + """Handle mouse clicks on the canvas""" + if not self.current_viewport: + return + + # Convert canvas coordinates to viewport coordinates + canvas_x = self.canvas.canvasx(event.x) + canvas_y = self.canvas.canvasy(event.y) + + # Use viewport hit testing + hit_element = self.current_viewport.hit_test((canvas_x, canvas_y)) + + if hit_element and hasattr(hit_element, '_callback'): + # Handle clickable elements + try: + result = hit_element._callback() + if result: + self.status_var.set(result) + + # For external links, open in system browser + if hasattr(hit_element, '_link') and hit_element._link.link_type == LinkType.EXTERNAL: + webbrowser.open(hit_element._link.location) + except Exception as e: + self.status_var.set(f"Click error: {str(e)}") + + def on_mouse_move(self, event): + """Handle mouse movement for hover effects""" + if not self.current_viewport: + return + + # Convert canvas coordinates to viewport coordinates + canvas_x = self.canvas.canvasx(event.x) + canvas_y = self.canvas.canvasy(event.y) + + # Check if mouse is over any clickable element + hit_element = self.current_viewport.hit_test((canvas_x, canvas_y)) + + if hit_element and hasattr(hit_element, '_callback'): + self.canvas.configure(cursor="hand2") + else: + self.canvas.configure(cursor="arrow") + + def add_to_history(self, url): + """Add URL to navigation history""" + # Remove any forward history + self.history = self.history[:self.history_index + 1] + + # Add new URL + self.history.append(url) + self.history_index = len(self.history) - 1 + + # Update navigation buttons + self.update_nav_buttons() + + def update_nav_buttons(self): + """Update the state of navigation buttons""" + self.back_btn.configure(state=tk.NORMAL if self.history_index > 0 else tk.DISABLED) + self.forward_btn.configure(state=tk.NORMAL if self.history_index < len(self.history) - 1 else tk.DISABLED) + + def go_back(self): + """Navigate back in history""" + if self.history_index > 0: + self.history_index -= 1 + url = self.history[self.history_index] + self.url_var.set(url) + self.navigate_to_url() + + def go_forward(self): + """Navigate forward in history""" + if self.history_index < len(self.history) - 1: + self.history_index += 1 + url = self.history[self.history_index] + self.url_var.set(url) + self.navigate_to_url() + + def refresh(self): + """Refresh the current page""" + current_url = self.url_var.get() + if current_url: + self.navigate_to_url() + else: + self.load_default_page() + + def run(self): + """Start the browser""" + self.root.mainloop() + + +def main(): + """Main function to run the enhanced browser""" + print("Starting pyWebLayout HTML Browser with Viewport System...") + + try: + browser = ViewportBrowserWindow() + browser.run() + except Exception as e: + print(f"Error starting browser: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/pyWebLayout/concrete/__init__.py b/pyWebLayout/concrete/__init__.py index 338fec7..371234c 100644 --- a/pyWebLayout/concrete/__init__.py +++ b/pyWebLayout/concrete/__init__.py @@ -3,3 +3,4 @@ from .page import Container, Page from .text import Text, Line from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField from .image import RenderableImage +from .viewport import Viewport, ScrollablePageContent diff --git a/pyWebLayout/concrete/page.py b/pyWebLayout/concrete/page.py index 64c9701..478cf1d 100644 --- a/pyWebLayout/concrete/page.py +++ b/pyWebLayout/concrete/page.py @@ -542,7 +542,7 @@ class Page(Container): break # Add the line if it has any words - if len(line.renderable_words) > 0: + if len(line._text_objects) > 0: lines.append(line) line_y_offset += line_height else: diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py index c7d1725..6fed18c 100644 --- a/pyWebLayout/concrete/text.py +++ b/pyWebLayout/concrete/text.py @@ -144,11 +144,11 @@ class JustifyAlignmentHandler(AlignmentHandler): if num_spaces > 0: projected_spacing = available_space // num_spaces - # Be more conservative about hyphenation - only suggest it if spacing would be very large - # Use a higher threshold to avoid unnecessary hyphenation - max_acceptable_spacing = spacing * 3 # Allow up to 3x normal spacing before hyphenating - # Also ensure we have a minimum threshold to avoid hyphenating for tiny improvements - min_threshold_for_hyphenation = spacing + 10 # At least 10 pixels above min spacing + # Be much more conservative about hyphenation - only suggest it if spacing would be extremely large + # Increase the threshold significantly to avoid mid-sentence hyphenation + max_acceptable_spacing = spacing * 5 # Allow up to 5x normal spacing before hyphenating + # Increase minimum threshold to make hyphenation much less likely + min_threshold_for_hyphenation = spacing + 20 # At least 20 pixels above min spacing return projected_spacing > max(max_acceptable_spacing, min_threshold_for_hyphenation) return False @@ -402,6 +402,54 @@ class Line(Box): """Set the next line in sequence""" self._next = line + def _calculate_available_width(self, font: Font) -> int: + """Calculate available width for adding a word.""" + min_spacing = self._spacing[0] + spacing_needed = min_spacing if self._text_objects else 0 + safety_margin = self._get_safety_margin(font) + return int(self._size[0] - self._current_width - spacing_needed - safety_margin) + + def _get_safety_margin(self, font: Font) -> int: + """Calculate safety margin to prevent text cropping.""" + return max(1, int(font.font_size * 0.05)) # 5% of font size + + def _fits_with_normal_spacing(self, word_width: int, available_width: int, font: Font) -> bool: + """Check if word fits with normal spacing.""" + if word_width > available_width: + return False + + # Check if alignment handler suggests hyphenation anyway + should_hyphenate = self._alignment_handler.should_try_hyphenation( + self._text_objects, word_width, available_width, self._spacing[0], font) + return not should_hyphenate + + def _add_word_with_normal_spacing(self, text: str, font: Font, word_width: int) -> None: + """Add word to line with normal spacing.""" + spacing_needed = self._spacing[0] if self._text_objects else 0 + + text_obj = Text(text, font) + text_obj.add_to_line(self) + self._text_objects.append(text_obj) + + self._current_width += spacing_needed + word_width + return None + + def _try_hyphenation(self, text: str, font: Font, available_width: int) -> Union[str, None]: + """Try hyphenation to fit part of the word.""" + spacing_needed = self._spacing[0] if self._text_objects else 0 + safety_margin = self._get_safety_margin(font) + return self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin) + + def _handle_word_overflow(self, text: str, font: Font, available_width: int) -> str: + """Handle case where word doesn't fit.""" + if self._text_objects: + # Line already has words, move this word to the next line + return text + else: + # Empty line with word that's too long - force fit as last resort + safety_margin = self._get_safety_margin(font) + return self._force_fit_long_word(text, font, available_width + safety_margin) + def _try_reduced_spacing_fit(self, text: str, font: Font, word_width: int, safety_margin: int) -> Union[None, str]: """ Try to fit the word by reducing spacing between existing words. @@ -490,14 +538,7 @@ class Line(Box): def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]: """ - Add a word to this line as a Text object using intelligent word fitting strategies. - - This method implements a comprehensive word fitting algorithm that: - 1. First tries to fit the word with normal spacing - 2. If that fails, tries reducing spacing to minimize gaps - 3. Uses hyphenation when beneficial for spacing quality - 4. Falls back to moving the word to the next line - 5. As a last resort, force-fits long words + Add a word to this line using intelligent word fitting strategies. Args: text: The text content of the word @@ -509,63 +550,26 @@ class Line(Box): if not font: font = self._font - # Create a Text object to measure the word - text_obj = Text(text, font) - word_width = text_obj.width + available_width = self._calculate_available_width(font) + word_width = Text(text, font).width - # If this is the first word, no spacing is needed - min_spacing, max_spacing = self._spacing - spacing_needed = min_spacing if self._text_objects else 0 + # Strategy 1: Try normal spacing first + if self._fits_with_normal_spacing(word_width, available_width, font): + return self._add_word_with_normal_spacing(text, font, word_width) - # Add a small margin to prevent edge cases where words appear to fit but get cropped - safety_margin = max(1, int(font.font_size * 0.05)) # 5% of font size as safety margin - - # Check if word fits in the line with safety margin - available_width = self._size[0] - self._current_width - spacing_needed - safety_margin - - # Strategy 1: Try to fit with normal spacing - if word_width <= available_width: - # Check if alignment handler suggests hyphenation for better spacing quality - should_hyphenate = self._alignment_handler.should_try_hyphenation( - self._text_objects, word_width, available_width, min_spacing, font) - - if not should_hyphenate: - # Word fits with normal spacing and no hyphenation needed - add it - text_obj.add_to_line(self) - self._text_objects.append(text_obj) - self._current_width += spacing_needed + word_width - return None - else: - # Word fits but hyphenation might improve spacing - try it - hyphen_result = self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin) - if hyphen_result is None: - # Hyphenation worked and improved spacing - return None - # If hyphenation didn't work or didn't improve things, fall through to add the whole word - text_obj.add_to_line(self) - self._text_objects.append(text_obj) - self._current_width += spacing_needed + word_width + # Strategy 2: Try reduced spacing + if self._text_objects: + result = self._try_reduced_spacing_fit(text, font, word_width, self._get_safety_margin(font)) + if result is None: return None - # Strategy 2: Try reducing spacing to maximize fit - if self._text_objects and word_width > available_width: - reduced_spacing_result = self._try_reduced_spacing_fit(text, font, word_width, safety_margin) - if reduced_spacing_result is None: - # Word fitted by reducing spacing - return None - - # Strategy 3: Try hyphenation to fit part of the word - hyphen_result = self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin) - if hyphen_result != text: # Some progress was made with hyphenation + # Strategy 3: Try hyphenation + hyphen_result = self._try_hyphenation(text, font, available_width) + if hyphen_result != text: return hyphen_result - # Strategy 4: Word doesn't fit and no hyphenation helped - if self._text_objects: - # Line already has words, move this word to the next line - return text - else: - # Empty line with word that's too long - force fit as last resort - return self._force_fit_long_word(text, font, available_width + safety_margin) + # Strategy 4: Handle overflow + return self._handle_word_overflow(text, font, available_width) def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int, spacing_needed: int, safety_margin: int) -> Union[None, str]: diff --git a/pyWebLayout/concrete/viewport.py b/pyWebLayout/concrete/viewport.py new file mode 100644 index 0000000..445e463 --- /dev/null +++ b/pyWebLayout/concrete/viewport.py @@ -0,0 +1,461 @@ +from typing import List, Tuple, Optional, Dict, Any +import numpy as np +from PIL import Image + +from pyWebLayout.core.base import Renderable, Layoutable +from .box import Box +from .page import Container +from pyWebLayout.style.layout import Alignment + + +class Viewport(Box, Layoutable): + """ + A viewport that provides a movable window into a larger content area. + + This class allows you to have a large layout containing many elements, + but only render the portion that's currently visible in the viewport. + This enables efficient scrolling and memory usage for large documents. + """ + + def __init__(self, viewport_size: Tuple[int, int], content_size: Optional[Tuple[int, int]] = None, + origin=(0, 0), callback=None, sheet=None, mode='RGBA', + background_color=(255, 255, 255)): + """ + Initialize a viewport. + + Args: + viewport_size: The size of the visible viewport window (width, height) + content_size: The total size of the content area (None for auto-sizing) + origin: The origin of the viewport in its parent container + callback: Optional callback function + sheet: Optional image sheet + mode: Image mode + background_color: Background color for the viewport + """ + super().__init__(origin, viewport_size, callback, sheet, mode) + + self._viewport_size = np.array(viewport_size) + self._content_size = np.array(content_size) if content_size else None + self._background_color = background_color + + # Viewport position within the content (scroll offset) + self._viewport_offset = np.array([0, 0]) + + # Content container that holds all the actual content + self._content_container = Container( + origin=(0, 0), + size=content_size or viewport_size, + direction='vertical', + spacing=0, + padding=(0, 0, 0, 0) + ) + + # Cached content bounds for optimization + self._content_bounds_cache = None + self._cache_dirty = True + + @property + def viewport_size(self) -> Tuple[int, int]: + """Get the viewport size""" + return tuple(self._viewport_size) + + @property + def content_size(self) -> Tuple[int, int]: + """Get the total content size""" + if self._content_size is not None: + return tuple(self._content_size) + else: + # Auto-calculate from content + self._update_content_size() + return tuple(self._content_size) + + @property + def viewport_offset(self) -> Tuple[int, int]: + """Get the current viewport offset (scroll position)""" + return tuple(self._viewport_offset) + + @property + def max_scroll_x(self) -> int: + """Get the maximum horizontal scroll position""" + content_w, content_h = self.content_size + viewport_w, viewport_h = self.viewport_size + return max(0, content_w - viewport_w) + + @property + def max_scroll_y(self) -> int: + """Get the maximum vertical scroll position""" + content_w, content_h = self.content_size + viewport_w, viewport_h = self.viewport_size + return max(0, content_h - viewport_h) + + def add_content(self, renderable: Renderable) -> 'Viewport': + """Add content to the viewport's content area""" + self._content_container.add_child(renderable) + self._cache_dirty = True + return self + + def clear_content(self) -> 'Viewport': + """Clear all content from the viewport""" + self._content_container._children.clear() + self._cache_dirty = True + return self + + def set_content_size(self, size: Tuple[int, int]) -> 'Viewport': + """Set the total content size explicitly""" + self._content_size = np.array(size) + self._content_container._size = self._content_size + self._cache_dirty = True + return self + + def _update_content_size(self): + """Auto-calculate content size from children""" + if not self._content_container._children: + self._content_size = self._viewport_size.copy() + return + + # Layout children to get their positions + self._content_container.layout() + + # Find the bounds of all children + max_x = 0 + max_y = 0 + + for child in self._content_container._children: + if hasattr(child, '_origin') and hasattr(child, '_size'): + child_origin = np.array(child._origin) + child_size = np.array(child._size) + child_end = child_origin + child_size + + max_x = max(max_x, child_end[0]) + max_y = max(max_y, child_end[1]) + + # Ensure content size is at least as large as viewport + self._content_size = np.array([ + max(max_x, self._viewport_size[0]), + max(max_y, self._viewport_size[1]) + ]) + + self._content_container._size = self._content_size + + def _get_content_bounds(self) -> List[Tuple]: + """Get bounds of all content elements for efficient intersection testing""" + if not self._cache_dirty and self._content_bounds_cache is not None: + return self._content_bounds_cache + + bounds = [] + self._collect_element_bounds(self._content_container, np.array([0, 0]), bounds) + + self._content_bounds_cache = bounds + self._cache_dirty = False + return bounds + + def _collect_element_bounds(self, container, offset: np.ndarray, bounds: List[Tuple]): + """Recursively collect bounds of all renderable elements""" + if not hasattr(container, '_children'): + return + + for child in container._children: + if hasattr(child, '_origin') and hasattr(child, '_size'): + child_origin = np.array(child._origin) + child_size = np.array(child._size) + + # Calculate absolute position + abs_origin = offset + child_origin + abs_end = abs_origin + child_size + + # Store bounds info + bounds.append((child, abs_origin, abs_end, child_size)) + + # Recursively process children + if hasattr(child, '_children'): + self._collect_element_bounds(child, abs_origin, bounds) + + def scroll_to(self, x: int, y: int) -> 'Viewport': + """ + Scroll the viewport to a specific position. + + Args: + x: Horizontal scroll position + y: Vertical scroll position + + Returns: + Self for method chaining + """ + # Clamp scroll position to valid range + max_x = self.max_scroll_x + max_y = self.max_scroll_y + + self._viewport_offset[0] = max(0, min(x, max_x)) + self._viewport_offset[1] = max(0, min(y, max_y)) + + return self + + def scroll_by(self, dx: int, dy: int) -> 'Viewport': + """ + Scroll the viewport by a relative amount. + + Args: + dx: Horizontal scroll delta + dy: Vertical scroll delta + + Returns: + Self for method chaining + """ + current_x, current_y = self.viewport_offset + return self.scroll_to(current_x + dx, current_y + dy) + + def scroll_to_top(self) -> 'Viewport': + """Scroll to the top of the content""" + return self.scroll_to(self._viewport_offset[0], 0) + + def scroll_to_bottom(self) -> 'Viewport': + """Scroll to the bottom of the content""" + return self.scroll_to(self._viewport_offset[0], self.max_scroll_y) + + def scroll_page_up(self) -> 'Viewport': + """Scroll up by one viewport height""" + return self.scroll_by(0, -self._viewport_size[1]) + + def scroll_page_down(self) -> 'Viewport': + """Scroll down by one viewport height""" + return self.scroll_by(0, self._viewport_size[1]) + + def scroll_line_up(self, line_height: int = 20) -> 'Viewport': + """Scroll up by one line""" + return self.scroll_by(0, -line_height) + + def scroll_line_down(self, line_height: int = 20) -> 'Viewport': + """Scroll down by one line""" + return self.scroll_by(0, line_height) + + def get_visible_elements(self) -> List[Tuple]: + """ + Get all elements that are currently visible in the viewport. + + Returns: + List of tuples (element, visible_origin, visible_size) for each visible element + """ + # Define viewport bounds + viewport_left = self._viewport_offset[0] + viewport_top = self._viewport_offset[1] + viewport_right = viewport_left + self._viewport_size[0] + viewport_bottom = viewport_top + self._viewport_size[1] + + visible_elements = [] + content_bounds = self._get_content_bounds() + + for element, abs_origin, abs_end, element_size in content_bounds: + # Check if element intersects with viewport + if (abs_origin[0] < viewport_right and abs_end[0] > viewport_left and + abs_origin[1] < viewport_bottom and abs_end[1] > viewport_top): + + # Calculate visible portion of the element + visible_left = max(abs_origin[0], viewport_left) + visible_top = max(abs_origin[1], viewport_top) + visible_right = min(abs_end[0], viewport_right) + visible_bottom = min(abs_end[1], viewport_bottom) + + # Calculate visible origin relative to viewport + visible_origin = np.array([ + visible_left - viewport_left, + visible_top - viewport_top + ]) + + # Calculate visible size + visible_size = np.array([ + visible_right - visible_left, + visible_bottom - visible_top + ]) + + # Calculate clipping info for the element + element_clip_x = visible_left - abs_origin[0] + element_clip_y = visible_top - abs_origin[1] + element_clip_w = visible_size[0] + element_clip_h = visible_size[1] + + visible_elements.append(( + element, + visible_origin, + visible_size, + (element_clip_x, element_clip_y, element_clip_w, element_clip_h) + )) + + return visible_elements + + def layout(self): + """Layout the content within the viewport""" + # Update content size if needed + if self._content_size is None: + self._update_content_size() + + # Layout all content + self._content_container.layout() + self._cache_dirty = True + + def render(self) -> Image.Image: + """ + Render only the visible portion of the content. + + Returns: + A PIL Image containing the rendered viewport + """ + # Ensure content is laid out + self.layout() + + # Create viewport canvas + canvas = Image.new(self._mode, tuple(self._viewport_size), self._background_color) + + # Get visible elements + visible_elements = self.get_visible_elements() + + # Render each visible element + for element, visible_origin, visible_size, clip_info in visible_elements: + try: + # Render the full element + element_img = element.render() + + # Extract the visible portion + clip_x, clip_y, clip_w, clip_h = clip_info + if clip_x >= 0 and clip_y >= 0 and clip_w > 0 and clip_h > 0: + # Ensure clipping bounds are within element image + elem_w, elem_h = element_img.size + clip_x = min(clip_x, elem_w) + clip_y = min(clip_y, elem_h) + clip_w = min(clip_w, elem_w - clip_x) + clip_h = min(clip_h, elem_h - clip_y) + + if clip_w > 0 and clip_h > 0: + # Crop the visible portion + visible_img = element_img.crop(( + clip_x, clip_y, + clip_x + clip_w, clip_y + clip_h + )) + + # Paste onto viewport canvas + paste_pos = tuple(visible_origin.astype(int)) + if visible_img.mode == 'RGBA' and canvas.mode == 'RGBA': + canvas.paste(visible_img, paste_pos, visible_img) + else: + canvas.paste(visible_img, paste_pos) + + except Exception as e: + # Skip elements that fail to render + continue + + return canvas + + def hit_test(self, point: Tuple[int, int]) -> Optional[Renderable]: + """ + Find the topmost element at the given viewport coordinates. + + Args: + point: Coordinates within the viewport + + Returns: + The element at the given point, or None + """ + viewport_x, viewport_y = point + + # Convert viewport coordinates to content coordinates + content_x = viewport_x + self._viewport_offset[0] + content_y = viewport_y + self._viewport_offset[1] + + # Find elements at this point (in reverse order for top-to-bottom hit testing) + content_bounds = self._get_content_bounds() + + for element, abs_origin, abs_end, element_size in reversed(content_bounds): + if (abs_origin[0] <= content_x < abs_end[0] and + abs_origin[1] <= content_y < abs_end[1]): + return element + + return None + + def get_scroll_info(self) -> Dict[str, Any]: + """ + Get information about the current scroll state. + + Returns: + Dictionary with scroll information + """ + content_w, content_h = self.content_size + viewport_w, viewport_h = self.viewport_size + offset_x, offset_y = self.viewport_offset + + return { + 'content_size': (content_w, content_h), + 'viewport_size': (viewport_w, viewport_h), + 'offset': (offset_x, offset_y), + 'max_scroll': (self.max_scroll_x, self.max_scroll_y), + 'scroll_progress_x': offset_x / max(1, self.max_scroll_x) if self.max_scroll_x > 0 else 0, + 'scroll_progress_y': offset_y / max(1, self.max_scroll_y) if self.max_scroll_y > 0 else 0, + 'can_scroll_up': offset_y > 0, + 'can_scroll_down': offset_y < self.max_scroll_y, + 'can_scroll_left': offset_x > 0, + 'can_scroll_right': offset_x < self.max_scroll_x + } + + +class ScrollablePageContent(Container): + """ + A specialized container for page content that's designed to work with viewports. + This extends the regular Page functionality but allows for much larger content areas. + """ + + def __init__(self, content_width: int = 800, initial_height: int = 1000, + direction='vertical', spacing=10, padding=(0, 0, 0, 0)): + """ + Initialize scrollable page content. + + Args: + content_width: Width of the content area + initial_height: Initial height (will grow as content is added) + direction: Layout direction + spacing: Spacing between elements + padding: Padding around content (no padding to avoid viewport clipping issues) + """ + super().__init__( + origin=(0, 0), + size=(content_width, initial_height), + direction=direction, + spacing=spacing, + padding=padding # No padding to avoid any positioning issues with viewport + ) + + self._content_width = content_width + self._auto_height = True + + def add_child(self, child: Renderable): + """Add a child and update content height if needed""" + super().add_child(child) + + if self._auto_height: + self._update_content_height() + + return self + + def _update_content_height(self): + """Update the content height based on children""" + if not self._children: + return + + # Layout children to get accurate positions + super().layout() + + # Find the bottom-most child + max_bottom = 0 + for child in self._children: + if hasattr(child, '_origin') and hasattr(child, '_size'): + child_bottom = child._origin[1] + child._size[1] + max_bottom = max(max_bottom, child_bottom) + + # Add some bottom padding + new_height = max_bottom + self._padding[2] + self._spacing + + # Update size if needed + if new_height > self._size[1]: + self._size = np.array([self._content_width, new_height]) + + def get_content_height(self) -> int: + """Get the total content height""" + self._update_content_height() + return self._size[1] diff --git a/tests/test_line_splitting_bug.py b/tests/test_line_splitting_bug.py index 22af1fc..1efaafd 100644 --- a/tests/test_line_splitting_bug.py +++ b/tests/test_line_splitting_bug.py @@ -102,6 +102,83 @@ class TestLineSplittingBug(unittest.TestCase): self.assertEqual(len(line.text_objects), 1) self.assertEqual(line.text_objects[0].text, "short") + def test_conservative_justified_hyphenation(self): + """Test that justified alignment is more conservative about mid-sentence hyphenation""" + font = Font(font_path=None, font_size=12, colour=(0, 0, 0)) + line = Line((5, 15), (0, 0), (200, 20), font, halign=Alignment.JUSTIFY) + + with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module: + mock_dic = Mock() + mock_pyphen_module.Pyphen.return_value = mock_dic + mock_dic.inserted.return_value = "test-word" + + # Add words that should fit without hyphenation + result1 = line.add_word("This") + result2 = line.add_word("should") + result3 = line.add_word("testword") # Should NOT be hyphenated with conservative settings + + self.assertIsNone(result1) + self.assertIsNone(result2) + self.assertIsNone(result3) # Should fit without hyphenation + self.assertEqual(len(line.text_objects), 3) + self.assertEqual([obj.text for obj in line.text_objects], ["This", "should", "testword"]) + + def test_helper_methods_exist(self): + """Test that refactored helper methods exist and work""" + font = Font(font_path=None, font_size=12, colour=(0, 0, 0)) + line = Line((5, 10), (0, 0), (200, 20), font) + + # Test helper methods exist and return reasonable values + available_width = line._calculate_available_width(font) + self.assertIsInstance(available_width, int) + self.assertGreater(available_width, 0) + + safety_margin = line._get_safety_margin(font) + self.assertIsInstance(safety_margin, int) + self.assertGreaterEqual(safety_margin, 1) + + fits = line._fits_with_normal_spacing(50, 100, font) + self.assertIsInstance(fits, bool) + + def test_no_cropping_with_safety_margin(self): + """Test that safety margin prevents text cropping""" + font = Font(font_path=None, font_size=12, colour=(0, 0, 0)) + + # Create a line that's just barely wide enough + line = Line((2, 5), (0, 0), (80, 20), font) + + # Add words that should fit with safety margin + result1 = line.add_word("test") + result2 = line.add_word("word") + + self.assertIsNone(result1) + self.assertIsNone(result2) + + # Verify both words were added + self.assertEqual(len(line.text_objects), 2) + self.assertEqual([obj.text for obj in line.text_objects], ["test", "word"]) + + def test_modular_word_fitting_strategies(self): + """Test that word fitting strategies work in proper order""" + font = Font(font_path=None, font_size=12, colour=(0, 0, 0)) + line = Line((5, 10), (0, 0), (80, 20), font) # Narrower line to force overflow + + # Test normal spacing strategy + result1 = line.add_word("short") + self.assertIsNone(result1) + + # Test that we can add multiple words + result2 = line.add_word("words") + self.assertIsNone(result2) + + # Test overflow handling with a definitely too-long word + result3 = line.add_word("verylongwordthatdefinitelywontfitinnarrowline") + self.assertIsNotNone(result3) # Should return overflow + + # Line should have the first two words only + self.assertEqual(len(line.text_objects), 2) + self.assertEqual([obj.text for obj in line.text_objects], ["short", "words"]) + def demonstrate_bug(): """Demonstrate the bug with a practical example"""