diff --git a/src/core/generation_engine.py b/src/core/generation_engine.py index 7479d6c..060cade 100644 --- a/src/core/generation_engine.py +++ b/src/core/generation_engine.py @@ -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