#!/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()