Files
superset2/superset/utils/screenshot_utils.py

193 lines
6.3 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import io
import logging
from typing import TYPE_CHECKING
from PIL import Image
logger = logging.getLogger(__name__)
# Time to wait after scrolling for content to settle and load (in milliseconds)
SCROLL_SETTLE_TIMEOUT_MS = 1000
if TYPE_CHECKING:
try:
from playwright.sync_api import Page
except ImportError:
Page = None
def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:
"""
Combine multiple screenshot tiles into a single vertical image.
Args:
screenshot_tiles: List of screenshot bytes in PNG format
Returns:
Combined screenshot as bytes
"""
if not screenshot_tiles:
return b""
if len(screenshot_tiles) == 1:
return screenshot_tiles[0]
try:
# Open all images
images = [Image.open(io.BytesIO(tile)) for tile in screenshot_tiles]
# Calculate total dimensions
total_width = max(img.width for img in images)
total_height = sum(img.height for img in images)
# Create combined image
combined = Image.new("RGB", (total_width, total_height), "white")
# Paste each tile
y_offset = 0
for img in images:
combined.paste(img, (0, y_offset))
y_offset += img.height
# Convert back to bytes
output = io.BytesIO()
combined.save(output, format="PNG")
return output.getvalue()
except Exception as e:
logger.exception("Failed to combine screenshot tiles: %s", e)
# Return the first tile as fallback
return screenshot_tiles[0]
def take_tiled_screenshot(
page: "Page", element_name: str, tile_height: int
) -> bytes | None:
"""
Take a tiled screenshot of a large dashboard by scrolling and capturing sections.
Args:
page: Playwright page object
element_name: CSS class name of the element to screenshot
tile_height: Height of each tile in pixels
Returns:
Combined screenshot bytes or None if failed
"""
try:
# Get the target element
element = page.locator(f".{element_name}")
element.wait_for(timeout=30000) # 30 second timeout
# Get dashboard dimensions and position
element_info = page.evaluate(f"""() => {{
const el = document.querySelector(".{element_name}");
const rect = el.getBoundingClientRect();
return {{
width: el.scrollWidth,
height: el.scrollHeight,
left: rect.left + window.scrollX,
top: rect.top + window.scrollY,
}};
}}""")
dashboard_width = element_info["width"]
dashboard_height = element_info["height"]
dashboard_left = element_info["left"]
dashboard_top = element_info["top"]
logger.info(
"Dashboard: %sx%spx at (%s, %s)",
dashboard_width,
dashboard_height,
dashboard_left,
dashboard_top,
)
# Calculate number of tiles needed
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
logger.info("Taking %s screenshot tiles", num_tiles)
screenshot_tiles = []
for i in range(num_tiles):
# Calculate scroll position to show this tile's content
scroll_y = dashboard_top + (i * tile_height)
page.evaluate(f"window.scrollTo(0, {scroll_y})")
logger.debug(
"Scrolled window to %s for tile %s/%s", scroll_y, i + 1, num_tiles
)
# Wait for scroll to settle and content to load
page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * tile_height
remaining_content = dashboard_height - tile_start_in_element
clip_height = min(tile_height, remaining_content)
clip_y = (
0
if tile_height < remaining_content
else tile_height - remaining_content
)
clip_x = dashboard_left
# Skip tile if dimensions are invalid (width or height <= 0)
# This can happen if element is completely scrolled out of viewport
if clip_height <= 0 or clip_y < 0:
logger.warning(
"Skipping tile %s/%s due to invalid clip dimensions: "
"x=%s, y=%s, width=%s, height=%s "
"(element may be scrolled out of viewport)",
i + 1,
num_tiles,
clip_x,
clip_y,
dashboard_width,
clip_height,
)
continue
# Clip to capture only the current tile portion of the element
clip = {
"x": clip_x,
"y": clip_y,
"width": dashboard_width,
"height": clip_height,
}
# Take screenshot with clipping to capture only this tile's content
tile_screenshot = page.screenshot(type="png", clip=clip)
screenshot_tiles.append(tile_screenshot)
logger.debug("Captured tile %s/%s with clip %s", i + 1, num_tiles, clip)
# Combine all tiles
logger.info("Combining screenshot tiles...")
combined_screenshot = combine_screenshot_tiles(screenshot_tiles)
return combined_screenshot
except Exception as e:
logger.exception("Tiled screenshot failed: %s", e)
return None