""" Text Texture Generation Engine Core functionality for generating texture images with text overlays. """ import math import bpy from array import array from ..utils.constants import has_text_fitting from ..utils.overlay_assets import ( SVGConversionError, ensure_svg_raster, is_svg_path, ) from ..utils.system import find_default_truetype_font def generate_texture_image(props, width, height): pass """ Generate the actual texture image with overlays. Args: props: Text texture properties object width: Target image width in pixels height: Target image height in pixels Returns: tuple: (diffuse_image, normal_map_image) or (diffuse_image, None) if normal map disabled Returns (None, None) on error """ try: pass # Enhanced texture generation with proper error handling and logging # Create Blender image directly without large numpy arrays img_name = f"TextTexture_{width}x{height}" if img_name in bpy.data.images: pass bpy.data.images.remove(bpy.data.images[img_name]) blender_img = bpy.data.images.new(img_name, width, height, alpha=True) # Use PIL to create a proper text texture instead of solid color # PIL dependency is declared in blender_manifest.toml and should be auto-installed by Blender try: pass from PIL import Image, ImageDraw, ImageFont # Determine resampling filters compatible with current Pillow version resampling_module = getattr(Image, 'Resampling', None) lanczos_filter = getattr(resampling_module, 'LANCZOS', getattr(Image, 'LANCZOS', Image.BICUBIC)) bicubic_filter = getattr(resampling_module, 'BICUBIC', getattr(Image, 'BICUBIC', Image.BILINEAR)) def _iter_enabled_components(): """Yield enabled composition components with their index.""" overlays = getattr(props, 'image_overlays', []) try: overlay_list = list(overlays) except TypeError: overlay_list = [] for index, overlay in enumerate(overlay_list): if not getattr(overlay, 'enabled', True): continue yield index, overlay def _calculate_overlay_position(overlay, overlay_size): """Calculate top-left pixel position for an overlay image.""" overlay_width, overlay_height = overlay_size canvas_width = width canvas_height = height mode = getattr(overlay, 'placement_mode', 'ABSOLUTE') or 'ABSOLUTE' x_norm = float(getattr(overlay, 'x_position', 0.5) or 0.5) y_norm = float(getattr(overlay, 'y_position', 0.5) or 0.5) center_x = x_norm * canvas_width center_y = y_norm * canvas_height spacing_ratio = max(0.0, float(getattr(overlay, 'text_spacing', 0.0) or 0.0)) / 100.0 if mode == 'APPEND': center_x = (canvas_width * 0.5) + (spacing_ratio * canvas_width) + (overlay_width / 2.0) elif mode == 'PREPEND': center_x = (canvas_width * 0.5) - (spacing_ratio * canvas_width) - (overlay_width / 2.0) x = int(round(center_x - overlay_width / 2.0)) y = int(round(center_y - overlay_height / 2.0)) max_x = max(0, canvas_width - overlay_width) max_y = max(0, canvas_height - overlay_height) x = max(0, min(max_x, x)) y = max(0, min(max_y, y)) return x, y def _make_text_overlay(component, base_font_size): """Return (image, baseline_offset, descent, render_meta) for a text component.""" text_value = getattr(component, 'text_content', '').strip() if not text_value: return None, None, None, None scale = getattr(component, 'scale', 1.0) try: scale = float(scale if scale is not None else 1.0) except (TypeError, ValueError): scale = 1.0 if not math.isfinite(scale) or scale <= 0: return None, None, None, None desired_size = max(4, int(round(max(base_font_size, 4) * scale))) text_font = load_font_for_size(desired_size) temp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1))) overlay_img = None baseline_offset = None component_descent = None render_meta = None # Prefer anchoring to the left baseline so we can align with the main text baseline. try: baseline_bbox = temp_draw.textbbox( (0, 0), text_value, font=text_font, anchor='ls' ) except Exception: baseline_bbox = None if baseline_bbox: left, top, right, bottom = baseline_bbox text_width = max(1, int(round(right - left))) text_height = max(1, int(round(bottom - top))) overlay_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0)) overlay_draw = ImageDraw.Draw(overlay_img) text_color = tuple(int(c * 255) for c in props.text_color[:4]) # Place the baseline at a consistent offset from the top of the overlay. baseline_offset = -top draw_x = -left draw_y = -top overlay_draw.text((draw_x, draw_y), text_value, font=text_font, fill=text_color, anchor='ls') else: # Fallback for PIL versions without baseline-aware textbbox. try: text_bbox = temp_draw.textbbox((0, 0), text_value, font=text_font) except Exception: width, height = temp_draw.textsize(text_value, font=text_font) text_bbox = (0, 0, width, height) left, top, right, bottom = text_bbox text_width = max(1, int(round(right - left))) text_height = max(1, int(round(bottom - top))) overlay_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0)) overlay_draw = ImageDraw.Draw(overlay_img) text_color = tuple(int(c * 255) for c in props.text_color[:4]) offset_x = -left offset_y = -top baseline_offset = offset_y overlay_draw.text((offset_x, offset_y), text_value, font=text_font, fill=text_color) content_box = overlay_img.getbbox() if overlay_img else None crop_left = crop_top = 0 if content_box: crop_left, crop_top, crop_right, crop_bottom = content_box overlay_img = overlay_img.crop(content_box) if baseline_offset is not None: baseline_offset -= crop_top component_descent = (crop_bottom - crop_top) - (baseline_offset or 0) else: component_descent = (overlay_img.height if overlay_img else 0) - (baseline_offset or 0) if overlay_img and (baseline_offset is None or baseline_offset < 0 or baseline_offset > overlay_img.height): try: ascent, descent = text_font.getmetrics() except Exception: ascent, descent = overlay_img.height, 0 baseline_offset = min(max(0, int(round(ascent))), overlay_img.height) component_descent = max(0, overlay_img.height - baseline_offset) baseline_offset = max(0, int(round(baseline_offset or 0))) component_descent = max(0, int(round(component_descent or 0))) render_meta = { 'text': text_value, 'font': text_font, 'color': tuple(int(c * 255) for c in props.text_color[:4]), 'draw_offset_x': (draw_x - crop_left) if baseline_bbox else (offset_x - crop_left), 'draw_offset_y': (draw_y - crop_top) if baseline_bbox else (offset_y - crop_top), 'anchor': 'ls' if baseline_bbox else None, 'width': overlay_img.width if overlay_img else 0, 'height': overlay_img.height if overlay_img else 0 } return overlay_img, baseline_offset, component_descent, render_meta def _make_image_overlay(component): """Return RGBA image for an image component or None.""" resolved_path = getattr(component, 'image_path', '') if not resolved_path: return None scale = getattr(component, 'scale', 1.0) try: scale = float(scale if scale is not None else 1.0) except (TypeError, ValueError): scale = 1.0 if not math.isfinite(scale) or scale <= 0: return None scale_to_apply = scale if is_svg_path(resolved_path): try: raster_info = ensure_svg_raster(resolved_path, scale) resolved_path = raster_info.path scale_to_apply = raster_info.applied_scale except SVGConversionError as svg_error: print(f"WARNING: Failed to rasterize SVG overlay '{component.image_path}': {svg_error}") return None try: with Image.open(resolved_path) as raw_overlay: overlay_img = raw_overlay.convert('RGBA') except Exception as overlay_error: print(f"WARNING: Failed to load overlay image '{resolved_path}': {overlay_error}") return None if scale_to_apply != 1.0: new_size = ( max(1, int(round(overlay_img.width * scale_to_apply))), max(1, int(round(overlay_img.height * scale_to_apply))) ) if new_size != overlay_img.size: overlay_img = overlay_img.resize(new_size, lanczos_filter) return overlay_img def _apply_component_layers(pil_canvas, stage, base_text_rect, base_font_size): """Composite enabled components according to z-index and placement.""" components_data = [] for index, component in _iter_enabled_components(): z_value = getattr(component, 'z_index', 0) if stage == 'before' and z_value >= 0: continue if stage == 'after' and z_value < 0: continue component_type = getattr(component, 'component_type', 'IMAGE') if component_type == 'TEXT': overlay_img, baseline_offset, component_descent, render_meta = _make_text_overlay(component, base_font_size) else: overlay_img = _make_image_overlay(component) baseline_offset = overlay_img.height if overlay_img is not None else None component_descent = overlay_img.height if overlay_img is not None else None render_meta = None if overlay_img is None and not render_meta: continue rotation = float(getattr(component, 'rotation', 0.0) or 0.0) direct_draw = False width_from_image = overlay_img.width if overlay_img is not None else 0 height_from_image = overlay_img.height if overlay_img is not None else 0 if component_type == 'TEXT' and render_meta and not (rotation % 360): direct_draw = True else: if overlay_img is None: continue if rotation % 360: overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter) width_from_image = overlay_img.width height_from_image = overlay_img.height width_value = 0 height_value = 0 if component_type == 'TEXT' and render_meta: if direct_draw: width_value = render_meta.get('width', width_from_image) height_value = render_meta.get('height', height_from_image) else: width_value = width_from_image height_value = height_from_image elif overlay_img is not None: width_value = width_from_image height_value = height_from_image components_data.append({ 'index': index, 'component': component, 'image': overlay_img, 'z': z_value, 'type': component_type, 'baseline_offset': baseline_offset, 'descent': component_descent, 'render_info': render_meta, 'direct_draw': direct_draw, 'rotation': rotation, 'width': width_value, 'height': height_value }) if not components_data: return pil_canvas sequential_groups = {'PREPEND': [], 'APPEND': []} absolute_entries = [] for entry in components_data: placement = getattr(entry['component'], 'placement_mode', 'ABSOLUTE') or 'ABSOLUTE' if placement in sequential_groups: sequential_groups[placement].append(entry) else: x_pos, y_pos = _calculate_overlay_position(entry['component'], entry['image'].size) entry['position'] = (x_pos, y_pos) absolute_entries.append(entry) base_center_x = width * 0.5 base_center_y = height * 0.5 base_left = base_center_x base_right = base_center_x base_top = base_center_y - (base_font_size / 2.0) base_bottom = base_center_y + (base_font_size / 2.0) base_baseline = base_bottom if base_text_rect: base_center_x = base_text_rect['center_x'] base_center_y = base_text_rect['center_y'] base_left = base_text_rect['left'] base_right = base_text_rect['right'] base_top = base_text_rect.get('top', base_top) base_bottom = base_text_rect.get('bottom', base_bottom) base_baseline = base_text_rect.get('baseline', base_bottom) else: base_top = max(0.0, min(float(height - base_font_size), base_top)) base_bottom = max(base_top + 1.0, min(float(height), base_bottom)) base_baseline = base_bottom def assign_sequential_positions(entries, placement): if not entries: return [] sorted_entries = sorted(entries, key=lambda item: item['index']) cursor = base_left if placement == 'PREPEND' else base_right assigned = [] for entry in sorted_entries: component = entry['component'] entry_width = entry.get('width', entry['image'].width if entry['image'] else 0) entry_height = entry.get('height', entry['image'].height if entry['image'] else 0) spacing_factor = float(getattr(component, 'text_spacing', 0.0) or 0.0) / 100.0 inter_spacing_factor = float(getattr(component, 'image_spacing', 0.0) or 0.0) / 100.0 spacing_pixels = spacing_factor * base_font_size inter_pixels = inter_spacing_factor * base_font_size if placement == 'PREPEND': cursor -= spacing_pixels cursor -= entry_width x_pos = cursor cursor -= inter_pixels else: cursor += spacing_pixels x_pos = cursor cursor += entry_width cursor += inter_pixels if base_text_rect: if entry['type'] == 'TEXT' and entry['baseline_offset'] is not None: y_pos = int(round(base_baseline - entry['baseline_offset'])) else: y_pos = int(round(base_bottom - entry_height)) else: center_y = base_center_y y_pos = int(round(center_y - entry_height / 2.0)) x_pos = int(round(x_pos)) # For sequential layouts (PREPEND/APPEND), we don't strictly clamp. # If the spacing is huge, it should render off-screen, not clump together at x=0 if placement == 'ABSOLUTE': y_pos = max(0, min(height - entry_height, y_pos)) x_pos = max(0, min(width - entry_width, x_pos)) entry['position'] = (x_pos, y_pos) assigned.append(entry) return assigned positioned_entries = absolute_entries positioned_entries.extend(assign_sequential_positions(sequential_groups['PREPEND'], 'PREPEND')) positioned_entries.extend(assign_sequential_positions(sequential_groups['APPEND'], 'APPEND')) positioned_entries.sort(key=lambda item: item['z']) draw_ctx = None for entry in positioned_entries: x_pos, y_pos = entry.get('position', (0, 0)) if entry.get('direct_draw') and entry.get('render_info'): if draw_ctx is None: draw_ctx = ImageDraw.Draw(pil_canvas) info = entry['render_info'] draw_pos = ( x_pos + info.get('draw_offset_x', 0), y_pos + info.get('draw_offset_y', 0) ) anchor = info.get('anchor') draw_ctx.text( draw_pos, info.get('text', ''), font=info.get('font'), fill=info.get('color'), anchor=anchor ) else: overlay_img = entry.get('image') if overlay_img is None: continue overlay_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img) pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer) return pil_canvas # Create PIL image with transparent background if props.background_transparent: pass pil_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) else: pass bg_color = tuple(int(c * 255) for c in props.background_color[:3]) + (255,) pil_img = Image.new('RGBA', (width, height), bg_color) base_text_rect = None final_font_size = props.font_size before_layers_applied = False # Draw text if provided (including prepend/append) full_text_parts = [] if props.prepend_text.strip(): pass full_text_parts.append(props.prepend_text.strip()) if props.text.strip(): pass full_text_parts.append(props.text.strip()) if props.append_text.strip(): pass full_text_parts.append(props.append_text.strip()) if full_text_parts: pass measure_draw = ImageDraw.Draw(pil_img) # Gather static widths (Images) and prepare dynamic components for fitting loop static_overlay_width = 0.0 dynamic_components = [] if hasattr(props, "image_overlays"): for component in props.image_overlays: if component.enabled and getattr(component, 'placement_mode', 'ABSOLUTE') in ['PREPEND', 'APPEND']: c_type = getattr(component, 'component_type', 'IMAGE') dynamic_components.append(component) if c_type == 'IMAGE': # Image width is static to font scaling resolved_path = getattr(component, 'image_path', '') if resolved_path: try: with Image.open(resolved_path) as raw: static_overlay_width += raw.width * float(getattr(component, 'scale', 1.0) or 1.0) except Exception: pass # Combine text based on layout mode if props.prepend_append_layout == 'HORIZONTAL': pass # For horizontal layout, use simple space separation with margin control if len(full_text_parts) == 1: pass full_text = full_text_parts[0] else: pass # Calculate number of spaces based on margin (but reasonable amounts) prepend_spaces = max(1, int(props.prepend_margin / 10)) if props.prepend_text.strip() else 0 append_spaces = max(1, int(props.append_margin / 10)) if props.append_text.strip() else 0 # Build text with controlled spacing text_parts_with_spacing = [] for i, part in enumerate(full_text_parts): pass if i == 0 and prepend_spaces > 0: pass # Add spacing after prepend text text_parts_with_spacing.append(part + " " * prepend_spaces) elif i == len(full_text_parts) - 1 and append_spaces > 0: # Add spacing before append text text_parts_with_spacing.append(" " * append_spaces + part) else: pass text_parts_with_spacing.append(part) full_text = " ".join(text_parts_with_spacing) layout_mode = "horizontal" else: # Vertical layout - keep parts separate for individual positioning full_text = "\n".join(full_text_parts) layout_mode = "vertical" # Determine candidate font paths based on user configuration candidate_paths = [] seen_candidates = set() for candidate in ( getattr(props, 'custom_font_path', '') if getattr(props, 'use_custom_font', False) else '', getattr(props, 'font_path', '') if getattr(props, 'font_path', 'default') != "default" else '', find_default_truetype_font() ): if candidate and candidate not in seen_candidates: candidate_paths.append(candidate) seen_candidates.add(candidate) failed_candidates = set() active_font_path = None def load_font_for_size(size): pass nonlocal active_font_path for path in candidate_paths: pass if path in failed_candidates: pass continue try: pass font_obj = ImageFont.truetype(path, size) active_font_path = path return font_obj except (OSError, IOError, AttributeError, TypeError) as font_error: pass failed_candidates.add(path) continue active_font_path = None return ImageFont.load_default() # Determine font size (with text fitting if enabled) font_size = props.font_size stroke_width = getattr(props, 'stroke_width', 0) if getattr(props, 'enable_stroke', False) else 0 stroke_color = tuple(int(c * 255) for c in getattr(props, 'stroke_color', (0, 0, 0, 1))[:4]) stroke_kwargs = {'stroke_width': stroke_width, 'stroke_fill': stroke_color} if stroke_width > 0 else {} bbox_stroke_kwargs = {'stroke_width': stroke_width} if stroke_width > 0 else {} if props.enable_text_fitting and has_text_fitting(): pass try: pass min_size = props.min_font_size max_size = props.max_font_size target_width = width * (1.0 - props.text_fitting_margin / 100.0) target_height = height * (1.0 - props.text_fitting_margin / 100.0) optimal_size = min_size for test_size in range(min_size, max_size + 1, 2): # Step by 2 for performance test_font = load_font_for_size(test_size) try: bbox = measure_draw.textbbox((0, 0), full_text, font=test_font, **bbox_stroke_kwargs) test_width = bbox[2] - bbox[0] test_height = bbox[3] - bbox[1] except Exception: # Fallback gracefully if font measurement fails test_width = test_size * len(full_text) * 0.6 + stroke_width * 2 test_height = test_size + stroke_width * 2 # Add dynamic components width + spacing mathematically for component in dynamic_components: c_type = getattr(component, 'component_type', 'IMAGE') c_spacing = (float(getattr(component, 'text_spacing', 0.0) or 0.0) / 100.0) * test_size c_inter_spacing = (float(getattr(component, 'image_spacing', 0.0) or 0.0) / 100.0) * test_size test_width += c_spacing + c_inter_spacing if c_type == 'TEXT' and component.text_content.strip(): try: c_bbox = measure_draw.textbbox((0, 0), component.text_content.strip(), font=test_font, **bbox_stroke_kwargs) test_width += (c_bbox[2] - c_bbox[0]) * float(getattr(component, 'scale', 1.0) or 1.0) except Exception: pass if test_width + static_overlay_width <= target_width and test_height <= target_height: optimal_size = test_size else: break font_size = optimal_size except Exception as e: pass font_size = props.font_size final_font_size = font_size font = load_font_for_size(font_size) try: base_ascent, base_descent = font.getmetrics() except Exception: base_ascent, base_descent = (final_font_size, 0) # Calculate text position and draw if layout_mode == "vertical" and props.prepend_append_layout == 'VERTICAL': pass # Draw each part separately for vertical layout y_offset = 0 total_height = 0 part_widths = [] part_baselines = [] # Calculate total height first part_heights = [] for part in full_text_parts: pass try: bbox = measure_draw.textbbox((0, 0), part, font=font, **bbox_stroke_kwargs) part_height = bbox[3] - bbox[1] part_width = bbox[2] - bbox[0] baseline = base_ascent except Exception: # Fallback if font measurement fails part_width = font_size * len(part) * 0.6 + stroke_width * 2 part_height = font_size + stroke_width * 2 baseline = base_ascent part_heights.append(part_height) part_widths.append(part_width) part_baselines.append(baseline) total_height += part_height # Add spacing between parts if len(full_text_parts) > 1: pass spacing = font_size // 4 # 25% of font size as line spacing total_height += spacing * (len(full_text_parts) - 1) # Center vertically start_y = (height - total_height) // 2 max_width = max(part_widths) if part_widths else 0 left_x = (width - max_width) // 2 base_text_rect = { 'left': float(left_x), 'right': float(left_x + max_width), 'top': float(start_y), 'bottom': float(start_y + total_height), 'center_x': float(left_x + max_width / 2.0), 'center_y': float(start_y + total_height / 2.0), 'baseline': float(start_y + base_ascent), 'descent': float(base_descent), 'draw_y': float(start_y), 'ascent': float(base_ascent) } pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size) before_layers_applied = True from PIL import ImageFilter text_color = tuple(int(c * 255) for c in props.text_color[:4]) # Create effect layers has_shadow = getattr(props, 'enable_shadow', False) has_glow = getattr(props, 'enable_glow', False) has_blur = getattr(props, 'enable_blur', False) shadow_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) if has_shadow else None glow_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) if has_glow else None text_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) if has_blur else None main_draw = ImageDraw.Draw(pil_img) if not has_blur else ImageDraw.Draw(text_layer) s_draw = ImageDraw.Draw(shadow_layer) if has_shadow else None g_draw = ImageDraw.Draw(glow_layer) if has_glow else None s_color = tuple(int(c * 255) for c in getattr(props, 'shadow_color', (0,0,0,1))[:4]) if has_shadow else None g_color = tuple(int(c * 255) for c in getattr(props, 'glow_color', (1,1,1,0.8))[:4]) if has_glow else None s_dx = getattr(props, 'shadow_offset_x', 8.0) if has_shadow else 0 s_dy = getattr(props, 'shadow_offset_y', 8.0) if has_shadow else 0 for i, part in enumerate(full_text_parts): part_width = part_widths[i] x = (width - part_width) // 2 # Center horizontally if has_shadow: s_draw.text((x + s_dx, current_y + s_dy), part, font=font, fill=s_color, **stroke_kwargs) if has_glow: g_draw.text((x, current_y), part, font=font, fill=g_color, **stroke_kwargs) main_draw.text((x, current_y), part, font=font, fill=text_color, **stroke_kwargs) current_y += part_heights[i] if i < len(full_text_parts) - 1: # Add spacing except after last part current_y += font_size // 4 # Apply effects and composite if has_shadow: s_blur = getattr(props, 'shadow_blur', 6.0) if s_blur > 0: shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=s_blur)) pil_img = Image.alpha_composite(pil_img, shadow_layer) if has_glow: g_blur = getattr(props, 'glow_blur', 10.0) if g_blur > 0: glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=g_blur)) pil_img = Image.alpha_composite(pil_img, glow_layer) if has_blur: t_blur = getattr(props, 'blur_radius', 2.0) if t_blur > 0: text_layer = text_layer.filter(ImageFilter.GaussianBlur(radius=t_blur)) pil_img = Image.alpha_composite(pil_img, text_layer) else: pass # Horizontal layout or single text left_offset = 0 right_offset = 0 top_offset = 0 bottom_offset = 0 try: text_bbox = measure_draw.textbbox((0, 0), full_text, font=font, **bbox_stroke_kwargs) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] left_offset = text_bbox[0] right_offset = text_bbox[2] top_offset = text_bbox[1] bottom_offset = text_bbox[3] except Exception: # Fallback for bitmap fonts that don't support textbbox text_width = font_size * len(full_text) * 0.6 + stroke_width * 2 text_height = font_size + stroke_width * 2 right_offset = text_width bottom_offset = text_height # Calculate total widths for PREPEND and APPEND components # to properly center the entire logical sequence. total_prepend_width = 0.0 total_append_width = 0.0 if hasattr(props, "image_overlays"): for component in props.image_overlays: if component.enabled and getattr(component, 'placement_mode', 'ABSOLUTE') in ['PREPEND', 'APPEND']: c_type = getattr(component, 'component_type', 'IMAGE') c_spacing = float(getattr(component, 'text_spacing', 0.0) or 0.0) / 100.0 * final_font_size c_inter_spacing = float(getattr(component, 'image_spacing', 0.0) or 0.0) / 100.0 * final_font_size c_width = 0 if c_type == 'TEXT' and component.text_content.strip(): try: c_bbox = measure_draw.textbbox((0, 0), component.text_content.strip(), font=font, **bbox_stroke_kwargs) c_width = c_bbox[2] - c_bbox[0] except Exception: pass elif c_type == 'IMAGE': resolved_path = getattr(component, 'image_path', '') if resolved_path: try: with Image.open(resolved_path) as raw: c_width = raw.width * float(getattr(component, 'scale', 1.0) or 1.0) except Exception: pass if getattr(component, 'placement_mode', 'ABSOLUTE') == 'PREPEND': total_prepend_width += (c_width + c_spacing + c_inter_spacing) else: total_append_width += (c_width + c_spacing + c_inter_spacing) # Center the entire logical block total_logical_width = total_prepend_width + text_width + total_append_width logical_start_x = (width - total_logical_width) // 2 x = logical_start_x + int(total_prepend_width) y = (height - text_height) // 2 left = x + left_offset right = x + right_offset top = y + top_offset bottom = y + bottom_offset base_text_rect = { 'left': float(left), 'right': float(right), 'top': float(top), 'bottom': float(bottom), 'center_x': float((left + right) / 2.0), 'center_y': float((top + bottom) / 2.0), 'baseline': float(y + base_ascent), 'descent': float(base_descent), 'draw_y': float(y), 'ascent': float(base_ascent) } pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size) before_layers_applied = True from PIL import ImageFilter text_color = tuple(int(c * 255) for c in props.text_color[:4]) # Create effect layers has_shadow = getattr(props, 'enable_shadow', False) has_glow = getattr(props, 'enable_glow', False) has_blur = getattr(props, 'enable_blur', False) shadow_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) if has_shadow else None glow_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) if has_glow else None text_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) if has_blur else None main_draw = ImageDraw.Draw(pil_img) if not has_blur else ImageDraw.Draw(text_layer) if has_shadow: s_draw = ImageDraw.Draw(shadow_layer) s_color = tuple(int(c * 255) for c in getattr(props, 'shadow_color', (0,0,0,1))[:4]) s_dx = getattr(props, 'shadow_offset_x', 8.0) s_dy = getattr(props, 'shadow_offset_y', 8.0) s_draw.text((x + s_dx, y + s_dy), full_text, font=font, fill=s_color, **stroke_kwargs) if has_glow: g_draw = ImageDraw.Draw(glow_layer) g_color = tuple(int(c * 255) for c in getattr(props, 'glow_color', (1,1,1,0.8))[:4]) g_draw.text((x, y), full_text, font=font, fill=g_color, **stroke_kwargs) main_draw.text((x, y), full_text, font=font, fill=text_color, **stroke_kwargs) # Apply effects and composite if has_shadow: s_blur = getattr(props, 'shadow_blur', 6.0) if s_blur > 0: shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=s_blur)) pil_img = Image.alpha_composite(pil_img, shadow_layer) if has_glow: g_blur = getattr(props, 'glow_blur', 10.0) if g_blur > 0: glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=g_blur)) pil_img = Image.alpha_composite(pil_img, glow_layer) if has_blur: t_blur = getattr(props, 'blur_radius', 2.0) if t_blur > 0: text_layer = text_layer.filter(ImageFilter.GaussianBlur(radius=t_blur)) pil_img = Image.alpha_composite(pil_img, text_layer) else: pass if not before_layers_applied: pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size) pil_img = _apply_component_layers(pil_img, 'after', base_text_rect, final_font_size) # Convert PIL image to Blender format (flip vertically to fix mirroring) pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue raw_bytes = pil_img.tobytes() normalized = array('f', (channel / 255.0 for channel in raw_bytes)) pixels_attr = getattr(blender_img, "pixels", None) if hasattr(pixels_attr, "foreach_set"): pixels_attr.foreach_set(normalized) else: blender_img.pixels[:] = normalized.tolist() return blender_img, None except ImportError as e: pass # PIL not available - should not happen with proper Blender manifest dependencies print(f"ERROR: PIL dependency not available despite manifest declaration: {e}") print(f"ERROR: Check if Blender properly installed addon dependencies from manifest") # Create a visible yellow error pattern to indicate dependency issue error_pattern = [1.0, 1.0, 0.0, 1.0] # Yellow color to indicate dependency problem total_pixels = width * height blender_img.pixels[:] = error_pattern * total_pixels return blender_img, None except Exception as e: pass print(f"ERROR: Texture generation failed: {e}") import traceback traceback.print_exc() return None, None def generate_preview(props, preview_width=None, preview_height=None): pass """ Generate a preview version of the texture at lower resolution. Args: props: Text texture properties object preview_width: Preview image width. Defaults to the texture width if available. preview_height: Preview image height. Defaults to the texture height if available. Returns: Blender image object or None on error """ try: pass # Use full texture resolution when not explicitly provided if not preview_width or preview_width <= 0: preview_width = getattr(props, 'texture_width', 512) or 512 if not preview_height or preview_height <= 0: preview_height = getattr(props, 'texture_height', 512) or 512 # Generate at preview resolution result = generate_texture_image(props, preview_width, preview_height) if result and result[0]: pass preview_img = result[0] return preview_img else: pass return None except Exception as e: pass import traceback traceback.print_exc() return None