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

This commit is contained in:
2025-06-08 17:00:41 +02:00
parent 4325c983b1
commit df775ee462
7 changed files with 1603 additions and 65 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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:
+68 -64
View File
@@ -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
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]