view port based browser and test
Some checks failed
Python CI / test (push) Has been cancelled

This commit is contained in:
Duncan Tourolle 2025-06-08 17:00:41 +02:00
parent 4325c983b1
commit df775ee462
7 changed files with 1603 additions and 65 deletions

224
demo_viewport_system.py Normal file
View File

@ -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()

View File

@ -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'<title>(.*?)</title>', 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('<Return>', 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('<Button-1>', self.on_click)
self.canvas.bind('<B1-Motion>', self.on_drag)
self.canvas.bind('<ButtonRelease-1>', self.on_release)
self.canvas.bind('<Motion>', self.on_mouse_move)
self.canvas.bind('<MouseWheel>', self.on_mouse_wheel) # Windows/Mac
self.canvas.bind('<Button-4>', self.on_mouse_wheel) # Linux scroll up
self.canvas.bind('<Button-5>', self.on_mouse_wheel) # Linux scroll down
# Keyboard shortcuts
self.root.bind('<Control-c>', self.copy_selection)
self.root.bind('<Control-a>', self.select_all)
self.root.bind('<Prior>', self.page_up) # Page Up
self.root.bind('<Next>', self.page_down) # Page Down
self.root.bind('<Home>', self.scroll_to_top) # Home
self.root.bind('<End>', self.scroll_to_bottom) # End
self.root.bind('<Up>', lambda e: self.scroll_by_lines(-1))
self.root.bind('<Down>', 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('<Button-3>', 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 = """
<html>
<head><title>pyWebLayout Browser with Viewport - Welcome</title></head>
<body>
<h1>Welcome to pyWebLayout Browser with Viewport System</h1>
<p>This enhanced browser uses the new viewport system for efficient scrolling through large documents.</p>
<h2>New Viewport Features:</h2>
<ul>
<li><b>Efficient Rendering:</b> Only visible content is rendered</li>
<li><b>Smooth Scrolling:</b> Mouse wheel, keyboard, and scrollbar support</li>
<li><b>Large Document Support:</b> Handle documents of any size</li>
<li><b>Memory Efficient:</b> Low memory usage even for huge pages</li>
</ul>
<h2>Scrolling Controls:</h2>
<p><b>Mouse Wheel:</b> Scroll up and down</p>
<p><b>Page Up/Down:</b> Scroll by viewport height</p>
<p><b>Home/End:</b> Jump to top/bottom</p>
<p><b>Arrow Keys:</b> Scroll line by line</p>
<p><b>Scrollbar:</b> Click and drag for precise positioning</p>
<h2>Text Selection:</h2>
<p>Click and drag to select text, then use <b>Ctrl+C</b> to copy</p>
<p>Use <b>Ctrl+A</b> to select all visible text</p>
<h3>Try scrolling with different methods!</h3>
<p>This page demonstrates the viewport system. All the content above and below is efficiently managed.</p>
<h2>Sample Content for Scrolling</h2>
<p>Here's some additional content to demonstrate scrolling capabilities:</p>
<h3>Lorem Ipsum</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
<h3>More Sample Text</h3>
<p>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
<h3>Even More Content</h3>
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos.</p>
<p>Qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.</p>
<p>Consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.</p>
<h2>Technical Details</h2>
<p>The viewport system works by:</p>
<ul>
<li>Creating a large content container that can hold any amount of content</li>
<li>Providing a viewport window that shows only a portion of the content</li>
<li>Efficiently calculating which elements are visible</li>
<li>Rendering only the visible elements</li>
<li>Supporting smooth scrolling through the content</li>
</ul>
<p>This allows handling of very large documents without performance issues.</p>
<h2>Load Your Own Content</h2>
<p>Use the "Open File" button to load local HTML files, or enter a URL in the address bar.</p>
</body>
</html>
"""
# 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()

View File

@ -3,3 +3,4 @@ from .page import Container, Page
from .text import Text, Line from .text import Text, Line
from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField
from .image import RenderableImage from .image import RenderableImage
from .viewport import Viewport, ScrollablePageContent

View File

@ -542,7 +542,7 @@ class Page(Container):
break break
# Add the line if it has any words # Add the line if it has any words
if len(line.renderable_words) > 0: if len(line._text_objects) > 0:
lines.append(line) lines.append(line)
line_y_offset += line_height line_y_offset += line_height
else: else:

View File

@ -144,11 +144,11 @@ class JustifyAlignmentHandler(AlignmentHandler):
if num_spaces > 0: if num_spaces > 0:
projected_spacing = available_space // num_spaces projected_spacing = available_space // num_spaces
# Be more conservative about hyphenation - only suggest it if spacing would be very large # Be much more conservative about hyphenation - only suggest it if spacing would be extremely large
# Use a higher threshold to avoid unnecessary hyphenation # Increase the threshold significantly to avoid mid-sentence hyphenation
max_acceptable_spacing = spacing * 3 # Allow up to 3x normal spacing before hyphenating max_acceptable_spacing = spacing * 5 # Allow up to 5x normal spacing before hyphenating
# Also ensure we have a minimum threshold to avoid hyphenating for tiny improvements # Increase minimum threshold to make hyphenation much less likely
min_threshold_for_hyphenation = spacing + 10 # At least 10 pixels above min spacing 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 projected_spacing > max(max_acceptable_spacing, min_threshold_for_hyphenation)
return False return False
@ -402,6 +402,54 @@ class Line(Box):
"""Set the next line in sequence""" """Set the next line in sequence"""
self._next = line 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]: 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. 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]: 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. Add a word to this line 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
Args: Args:
text: The text content of the word text: The text content of the word
@ -509,63 +550,26 @@ class Line(Box):
if not font: if not font:
font = self._font font = self._font
# Create a Text object to measure the word available_width = self._calculate_available_width(font)
text_obj = Text(text, font) word_width = Text(text, font).width
word_width = text_obj.width
# If this is the first word, no spacing is needed # Strategy 1: Try normal spacing first
min_spacing, max_spacing = self._spacing if self._fits_with_normal_spacing(word_width, available_width, font):
spacing_needed = min_spacing if self._text_objects else 0 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 # Strategy 2: Try reduced spacing
safety_margin = max(1, int(font.font_size * 0.05)) # 5% of font size as safety margin if self._text_objects:
result = self._try_reduced_spacing_fit(text, font, word_width, self._get_safety_margin(font))
# Check if word fits in the line with safety margin if result is None:
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
return None return None
# Strategy 2: Try reducing spacing to maximize fit # Strategy 3: Try hyphenation
if self._text_objects and word_width > available_width: hyphen_result = self._try_hyphenation(text, font, available_width)
reduced_spacing_result = self._try_reduced_spacing_fit(text, font, word_width, safety_margin) if hyphen_result != text:
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
return hyphen_result return hyphen_result
# Strategy 4: Word doesn't fit and no hyphenation helped # Strategy 4: Handle overflow
if self._text_objects: return self._handle_word_overflow(text, font, available_width)
# 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)
def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int, def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int,
spacing_needed: int, safety_margin: int) -> Union[None, str]: spacing_needed: int, safety_margin: int) -> Union[None, str]:

View File

@ -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]

View File

@ -102,6 +102,83 @@ class TestLineSplittingBug(unittest.TestCase):
self.assertEqual(len(line.text_objects), 1) self.assertEqual(len(line.text_objects), 1)
self.assertEqual(line.text_objects[0].text, "short") 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(): def demonstrate_bug():
"""Demonstrate the bug with a practical example""" """Demonstrate the bug with a practical example"""