This commit is contained in:
parent
4325c983b1
commit
df775ee462
224
demo_viewport_system.py
Normal file
224
demo_viewport_system.py
Normal 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()
|
||||
771
html_browser_with_viewport.py
Normal file
771
html_browser_with_viewport.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]:
|
||||
|
||||
461
pyWebLayout/concrete/viewport.py
Normal file
461
pyWebLayout/concrete/viewport.py
Normal 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]
|
||||
@ -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"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user