Files
Text-Texture-Generator-for-…/src/core/generation_engine.py

889 lines
46 KiB
Python

"""
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