889 lines
46 KiB
Python
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
|