diff --git a/pixel_renderer/renderer.py b/pixel_renderer/renderer.py index 25c7a84..c78f649 100644 --- a/pixel_renderer/renderer.py +++ b/pixel_renderer/renderer.py @@ -17,41 +17,34 @@ from PIL import ImageFont # noqa: E402, F401, I001 from gi.repository import Pango, PangoCairo # noqa: E402 -# Reusable measurement context - avoids creating new surface/context/layout per call -# This provides ~10% speedup by reusing Pango layout object -_measurement_context = None - -# Reusable rendering surface - avoids creating new surface per call -# Keyed by block_size (height), provides ~10% additional speedup -_render_surfaces = {} -_MAX_RENDER_WIDTH = 1024 # Max width for reusable surface - - -def _get_measurement_layout(): - """Get or create a reusable Pango layout for text measurement.""" - global _measurement_context - if _measurement_context is None: - temp_surface = cairo.ImageSurface(cairo.FORMAT_RGB24, 1, 1) - temp_ctx = cairo.Context(temp_surface) - try: - layout = PangoCairo.create_layout(temp_ctx) - except KeyError as e: - if "could not find foreign type Context" in str(e): - raise RuntimeError( - "Pango/Cairo not properly installed. See https://github.com/sign/WeLT/issues/31" - ) from e - raise - _measurement_context = (temp_surface, temp_ctx, layout) - return _measurement_context[2] - - -def _get_render_surface(block_size: int): - """Get or create a reusable rendering surface for the given block size.""" - if block_size not in _render_surfaces: - surface = cairo.ImageSurface(cairo.FORMAT_RGB24, _MAX_RENDER_WIDTH, block_size) +_MIN_CANVAS_WIDTH = 1024 + + +@cache +def _get_layout() -> Pango.Layout: + """A reusable Pango layout, shared by all calls.""" + context = cairo.Context(cairo.ImageSurface(cairo.FORMAT_RGB24, 1, 1)) + try: + return PangoCairo.create_layout(context) + except KeyError as e: + if "could not find foreign type Context" in str(e): + raise RuntimeError("Pango/Cairo not properly installed. See https://github.com/sign/WeLT/issues/31") from e + raise + + +_canvases = {} + + +def _get_canvas(height: int, width: int): + """A reusable (surface, context, pixels) canvas per height, grown when a wider one is needed.""" + canvas = _canvases.get(height) + if canvas is None or canvas[0].get_width() < width: + surface = cairo.ImageSurface(cairo.FORMAT_RGB24, max(width, _MIN_CANVAS_WIDTH), height) context = cairo.Context(surface) - _render_surfaces[block_size] = (surface, context) - return _render_surfaces[block_size] + context.set_source_rgb(0, 0, 0) # Text is always black + pixels = np.frombuffer(surface.get_data(), dtype=np.uint8).reshape(height, surface.get_stride() // 4, 4) + canvas = _canvases[height] = (surface, context, pixels) + return canvas def dim_to_block_size(value: int, block_size: int) -> int: @@ -86,10 +79,7 @@ def render_signwriting(text: str, block_size: int = 16) -> np.ndarray: new_image = Image.new("RGB", (width, height), color=(255, 255, 255)) padding = (width - image.width) // 2, (height - image.height) // 2 new_image.paste(image, padding, image) - # Explicitly convert PIL Image to numpy array with np.array() before ascontiguousarray() - # This ensures correct shape interpretation regardless of PIL's internal buffer layout - arr = np.array(new_image, dtype=np.uint8) - return np.ascontiguousarray(arr) + return np.array(new_image, dtype=np.uint8) @cache @@ -103,7 +93,7 @@ def render_text(text: str, block_size: int = 16, font_size: int = 12) -> np.ndar Args: text (str): The text to render on a single line - block_size (int): Height of each line in pixels, and width scale (default: 32) + block_size (int): Height of each line in pixels, and width scale (default: 16) font_size (int): Font size (default: 12) Returns: @@ -112,54 +102,25 @@ def render_text(text: str, block_size: int = 16, font_size: int = 12) -> np.ndar if is_swu(text): return render_signwriting(text, block_size=block_size) - text = visualize_control_tokens(text, include_whitespace=True) - - # Get reusable layout for text measurement (avoids creating new surface/context/layout each call) - layout = _get_measurement_layout() - - # Set font and measure text - font_desc = cached_font_description("sans", font_size) - layout.set_font_description(font_desc) - layout.set_text(text, -1) + layout = _get_layout() + layout.set_font_description(cached_font_description("sans", font_size)) + layout.set_text(visualize_control_tokens(text, include_whitespace=True), -1) text_width, text_height = layout.get_pixel_size() # Add padding and round up to nearest multiple of block_size width = dim_to_block_size(text_width + 10, block_size=block_size) - line_height = block_size - - # Get reusable surface if width fits, otherwise create new one - if width <= _MAX_RENDER_WIDTH: - surface, context = _get_render_surface(block_size) - surface_width = _MAX_RENDER_WIDTH - else: - # Text too wide for reusable surface, create dedicated one - surface = cairo.ImageSurface(cairo.FORMAT_RGB24, width, line_height) - context = cairo.Context(surface) - surface_width = width - - # Fill white background (only the area we need) - context.set_source_rgb(1.0, 1.0, 1.0) - context.rectangle(0, 0, width, line_height) - context.fill() - - # Set black text color - context.set_source_rgb(0.0, 0.0, 0.0) - # Position text (left-aligned horizontally, vertically within its line) - x = 5 # Small left padding - y = (line_height - text_height) // 2 - context.move_to(x, y) + surface, context, pixels = _get_canvas(block_size, width) + pixels = pixels[:, :width] + pixels.fill(255) # White background, only the area we need + surface.mark_dirty() - # Render text + # Left-aligned with a small padding, vertically centered + context.move_to(5, (block_size - text_height) // 2) PangoCairo.show_layout(context, layout) + surface.flush() - # Extract image data as numpy array - data = surface.get_data() - bgra = np.frombuffer(data, dtype=np.uint8).reshape((line_height, surface_width, 4)) - # Slice to actual width if using reusable surface - if surface_width > width: - bgra = bgra[:, :width, :] - return bgra_to_rgb(bgra) + return bgra_to_rgb(pixels) def render_text_image(text: str, block_size: int = 16, font_size: int = 12) -> Image.Image: