fix(composition): unify effect pipeline for composition components and fix horizontal layout measurement v1.2.5

This commit is contained in:
2026-04-10 13:17:34 +02:00
parent 2df6d3bdc2
commit b0a86ee059

View File

@@ -100,7 +100,7 @@ def generate_texture_image(props, width, height):
y = max(0, min(max_y, y))
return x, y
def _make_text_overlay(component, base_font_size):
def _make_text_overlay(component, base_font_size, stroke_kwargs={}):
"""Return (image, baseline_offset, descent, render_meta) for a text component."""
text_value = getattr(component, 'text_content', '').strip()
if not text_value:
@@ -115,6 +115,10 @@ def generate_texture_image(props, width, height):
desired_size = max(4, int(round(max(base_font_size, 4) * scale)))
text_font = load_font_for_size(desired_size)
# Prepare stroke measurement for the component
comp_stroke_width = stroke_kwargs.get('stroke_width', 0)
bbox_stroke_kwargs = {'stroke_width': comp_stroke_width} if comp_stroke_width > 0 else {}
temp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
overlay_img = None
@@ -128,7 +132,8 @@ def generate_texture_image(props, width, height):
(0, 0),
text_value,
font=text_font,
anchor='ls'
anchor='ls',
**bbox_stroke_kwargs
)
except Exception:
baseline_bbox = None
@@ -144,19 +149,19 @@ def generate_texture_image(props, width, height):
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')
overlay_draw.text((draw_x, draw_y), text_value, font=text_font, fill=text_color, anchor='ls', **stroke_kwargs)
else:
# Fallback for PIL versions without baseline-aware textbbox.
try:
text_bbox = temp_draw.textbbox((0, 0), text_value, font=text_font)
text_bbox = temp_draw.textbbox((0, 0), text_value, font=text_font, **bbox_stroke_kwargs)
except Exception:
try:
font_size_val = int(getattr(props, 'font_size', 100))
except (ValueError, TypeError):
font_size_val = 100
width = font_size_val * len(text_value) * 0.6
height = font_size_val
text_bbox = (0, 0, width, height)
width_est = font_size_val * len(text_value) * 0.6 + comp_stroke_width * 2
height_est = font_size_val + comp_stroke_width * 2
text_bbox = (0, 0, width_est, height_est)
left, top, right, bottom = text_bbox
text_width = max(1, int(round(right - left)))
text_height = max(1, int(round(bottom - top)))
@@ -166,7 +171,7 @@ def generate_texture_image(props, width, height):
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)
overlay_draw.text((offset_x, offset_y), text_value, font=text_font, fill=text_color, **stroke_kwargs)
content_box = overlay_img.getbbox() if overlay_img else None
crop_left = crop_top = 0
@@ -237,7 +242,7 @@ def generate_texture_image(props, width, height):
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):
def _apply_component_layers(pil_canvas, stage, base_text_rect, base_font_size, s_draw=None, g_draw=None, s_dx=0.0, s_dy=0.0, stroke_kwargs={}):
"""Composite enabled components according to z-index and placement."""
components_data = []
for index, component in _iter_enabled_components():
@@ -248,7 +253,7 @@ def generate_texture_image(props, width, height):
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)
overlay_img, baseline_offset, component_descent, render_meta = _make_text_overlay(component, base_font_size, stroke_kwargs)
else:
overlay_img = _make_image_overlay(component)
baseline_offset = overlay_img.height if overlay_img is not None else None
@@ -379,29 +384,58 @@ def generate_texture_image(props, width, height):
positioned_entries.sort(key=lambda item: item['z'])
draw_ctx = None
# Main canvas drawing context
draw_ctx = ImageDraw.Draw(pil_canvas)
# Effect background/shadow colors
s_color = tuple(int(c * 255) for c in getattr(props, 'shadow_color', (0,0,0,1))[:4]) if s_draw else None
g_color = tuple(int(c * 255) for c in getattr(props, 'glow_color', (1,1,1,0.8))[:4]) if g_draw else None
for entry in positioned_entries:
x_pos, y_pos = entry.get('position', (0, 0))
is_text = (entry['type'] == 'TEXT')
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')
text_val = info.get('text', '')
text_font = info.get('font')
# Apply shadow to the shadow layer if drawing context exists
if s_draw and is_text:
s_pos = (draw_pos[0] + s_dx, draw_pos[1] + s_dy)
s_draw.text(s_pos, text_val, font=text_font, fill=s_color, anchor=anchor, **stroke_kwargs)
# Apply glow to glow layer
if g_draw and is_text:
g_draw.text(draw_pos, text_val, font=text_font, fill=g_color, anchor=anchor, **stroke_kwargs)
# Apply main text
draw_ctx.text(
draw_pos,
info.get('text', ''),
font=info.get('font'),
text_val,
font=text_font,
fill=info.get('color'),
anchor=anchor
anchor=anchor,
**stroke_kwargs
)
else:
overlay_img = entry.get('image')
if overlay_img is None:
continue
# Paste shadow for component? Actually images don't get the same stroke/shadow easily
# but we can paste them into shadow/glow layers if they are icons.
if is_text: # Only apply shadow/glow to text components for now
if s_draw:
s_draw.bitmap((x_pos + s_dx, y_pos + s_dy), overlay_img, fill=s_color)
if g_draw:
g_draw.bitmap((x_pos, y_pos), overlay_img, fill=g_color)
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)
@@ -420,6 +454,11 @@ def generate_texture_image(props, width, height):
base_text_rect = None
final_font_size = props.font_size
before_layers_applied = False
# Initialize effect-related variables early to avoid UnboundLocalError
s_draw, g_draw = None, None
s_dx, s_dy = 0.0, 0.0
stroke_kwargs = {}
# Draw text if provided (including prepend/append)
full_text_parts = []
@@ -644,12 +683,9 @@ def generate_texture_image(props, width, height):
'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
# Pre-initialize effect layers and drawing contexts
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)
@@ -658,18 +694,21 @@ def generate_texture_image(props, width, height):
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
try:
s_dx = float(getattr(props, 'shadow_offset_x', 8.0)) if has_shadow else 0.0
s_dy = float(getattr(props, 'shadow_offset_y', 8.0)) if has_shadow else 0.0
except (ValueError, TypeError):
s_dx, s_dy = 0.0, 0.0
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size, s_draw=s_draw, g_draw=g_draw, s_dx=s_dx, s_dy=s_dy, stroke_kwargs=stroke_kwargs)
before_layers_applied = True
main_draw = ImageDraw.Draw(pil_img) if not has_blur else ImageDraw.Draw(text_layer)
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
current_y = start_y
for i, part in enumerate(full_text_parts):
@@ -752,11 +791,10 @@ def generate_texture_image(props, width, height):
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
# Use the same helper to get the final (possibly cropped/stroked) width
temp_img, _, _, meta = _make_text_overlay(component, final_font_size, stroke_kwargs)
if meta:
c_width = meta.get('width', 0)
elif c_type == 'IMAGE':
resolved_path = getattr(component, 'image_path', '')
if resolved_path:
@@ -794,12 +832,9 @@ def generate_texture_image(props, width, height):
'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
# Pre-initialize effect layers and drawing contexts
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)
@@ -808,21 +843,26 @@ def generate_texture_image(props, width, height):
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
s_draw = ImageDraw.Draw(shadow_layer) if has_shadow else None
g_draw = ImageDraw.Draw(glow_layer) if has_glow else None
try:
s_dx = float(getattr(props, 'shadow_offset_x', 8.0)) if has_shadow else 0.0
s_dy = float(getattr(props, 'shadow_offset_y', 8.0)) if has_shadow else 0.0
except (ValueError, TypeError):
s_dx, s_dy = 0.0, 0.0
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size, s_draw=s_draw, g_draw=g_draw, s_dx=s_dx, s_dy=s_dy, stroke_kwargs=stroke_kwargs)
before_layers_applied = True
main_draw = ImageDraw.Draw(pil_img) if not has_blur else ImageDraw.Draw(text_layer)
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
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])
try:
s_dx = float(getattr(props, 'shadow_offset_x', 8.0))
s_dy = float(getattr(props, 'shadow_offset_y', 8.0))
except (ValueError, TypeError):
s_dx, s_dy = 0.0, 0.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)
@@ -858,8 +898,8 @@ def generate_texture_image(props, width, height):
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)
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size, s_draw=s_draw, g_draw=g_draw, s_dx=s_dx, s_dy=s_dy, stroke_kwargs=stroke_kwargs)
pil_img = _apply_component_layers(pil_img, 'after', base_text_rect, final_font_size, s_draw=s_draw, g_draw=g_draw, s_dx=s_dx, s_dy=s_dy, stroke_kwargs=stroke_kwargs)
# Convert PIL image to Blender format (flip vertically to fix mirroring)
pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue