feat: enhance generation engine and preset management system

This commit is contained in:
2026-04-10 12:46:02 +02:00
parent f8dc03caf9
commit 448217bc2f
21 changed files with 627 additions and 116 deletions

View File

@@ -331,8 +331,8 @@ def generate_texture_image(props, width, height):
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 * width
inter_pixels = inter_spacing_factor * width
spacing_pixels = spacing_factor * base_font_size
inter_pixels = inter_spacing_factor * base_font_size
if placement == 'PREPEND':
cursor -= spacing_pixels
@@ -353,8 +353,11 @@ def generate_texture_image(props, width, height):
center_y = base_center_y
y_pos = int(round(center_y - entry_height / 2.0))
x_pos = int(round(x_pos))
y_pos = max(0, min(height - entry_height, y_pos))
x_pos = max(0, min(width - entry_width, 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
@@ -423,6 +426,26 @@ def generate_texture_image(props, width, height):
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
@@ -452,11 +475,12 @@ def generate_texture_image(props, width, height):
text_parts_with_spacing.append(part)
full_text = " ".join(text_parts_with_spacing)
layout_mode = "horizontal"
else:
pass
# Vertical layout - keep parts separate for individual positioning
full_text = "\n".join(full_text_parts)
layout_mode = "vertical"
@@ -497,6 +521,11 @@ def generate_texture_image(props, width, height):
# 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
@@ -509,22 +538,34 @@ def generate_texture_image(props, width, height):
optimal_size = min_size
for test_size in range(min_size, max_size + 1, 2): # Step by 2 for performance
pass
test_font = load_font_for_size(test_size)
try:
bbox = measure_draw.textbbox((0, 0), full_text, font=test_font)
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 (ValueError, AttributeError):
# Fallback for bitmap fonts
test_width, test_height = measure_draw.textsize(full_text, font=test_font)
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
if test_width <= target_width and test_height <= target_height:
pass
# 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:
pass
break
font_size = optimal_size
@@ -553,13 +594,14 @@ def generate_texture_image(props, width, height):
for part in full_text_parts:
pass
try:
bbox = measure_draw.textbbox((0, 0), part, font=font)
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 (ValueError, AttributeError):
# Fallback for bitmap fonts
part_width, part_height = measure_draw.textsize(part, font=font)
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)
@@ -590,22 +632,61 @@ def generate_texture_image(props, width, height):
}
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size)
before_layers_applied = True
draw = ImageDraw.Draw(pil_img)
# Draw each part
current_y = start_y
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):
pass
part_width = part_widths[i]
x = (width - part_width) // 2 # Center horizontally
draw.text((x, current_y), part, font=font, fill=text_color)
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
@@ -616,22 +697,60 @@ def generate_texture_image(props, width, height):
bottom_offset = 0
try:
text_bbox = measure_draw.textbbox((0, 0), full_text, font=font)
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 (ValueError, AttributeError):
except Exception:
# Fallback for bitmap fonts that don't support textbbox
text_width, text_height = measure_draw.textsize(full_text, font=font)
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
# Center text
x = (width - text_width) // 2
# 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
@@ -650,11 +769,52 @@ def generate_texture_image(props, width, height):
}
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size)
before_layers_applied = True
draw = ImageDraw.Draw(pil_img)
# Draw text
from PIL import ImageFilter
text_color = tuple(int(c * 255) for c in props.text_color[:4])
draw.text((x, y), full_text, font=font, fill=text_color)
# 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