6 Commits

38 changed files with 152 additions and 51 deletions

19
.gitignore vendored
View File

@@ -68,10 +68,21 @@ desktop.ini
# Project-specific directories and files # Project-specific directories and files
.benchmarks/ .benchmarks/
# Marketing - Large binary files # Marketing - Large binary files and assets
marketing/screenrecords/*.mp4 marketing/**/*.mp4
marketing/voice/*.wav marketing/**/*.mov
marketing/*.psd marketing/**/*.wav
marketing/**/*.psd
marketing/**/*.blend
marketing/**/*.blend[0-9]
marketing/screenrecords/
marketing/voice/
marketing/thumbnails/
# Build and Distribution
dist/
*.whl
src/blender_manifest.toml
# Temporary files # Temporary files
*.tmp *.tmp

BIN
dist/text-texture-generator_1.0.0.zip vendored Normal file

Binary file not shown.

BIN
dist/text_texture_generator_v1.2.5.zip vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

38
src/blender_manifest.toml Normal file
View File

@@ -0,0 +1,38 @@
schema_version = "1.0.0"
# Module ID
id = "text_texture_generator"
# Module version
version = "1.2.5"
# Display name
name = "Text Texture Generator Pro"
# Short description
tagline = "Generate image textures from text with accurate dimensions and instant live preview"
# Maintainer
maintainer = "Marc Mintel <marc@mintel.me>"
# Version: standard or community
type = "add-on"
# Support: Community, Official or Core
support = "COMMUNITY"
# Website URI
website = "https://mintel.me"
# Common tags
tags = ["Material", "Shader", "Texture", "Text", "Image"]
# Minimum Blender version
blender_version_min = "4.0.0"
# License
license = ["GPL-3.0-or-later"]
[permissions]
# files = "Read and write access to files"
# network = "Access to the Internet"

View File

@@ -100,7 +100,7 @@ def generate_texture_image(props, width, height):
y = max(0, min(max_y, y)) y = max(0, min(max_y, y))
return x, 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.""" """Return (image, baseline_offset, descent, render_meta) for a text component."""
text_value = getattr(component, 'text_content', '').strip() text_value = getattr(component, 'text_content', '').strip()
if not text_value: 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))) desired_size = max(4, int(round(max(base_font_size, 4) * scale)))
text_font = load_font_for_size(desired_size) 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))) temp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
overlay_img = None overlay_img = None
@@ -128,7 +132,8 @@ def generate_texture_image(props, width, height):
(0, 0), (0, 0),
text_value, text_value,
font=text_font, font=text_font,
anchor='ls' anchor='ls',
**bbox_stroke_kwargs
) )
except Exception: except Exception:
baseline_bbox = None baseline_bbox = None
@@ -144,16 +149,19 @@ def generate_texture_image(props, width, height):
baseline_offset = -top baseline_offset = -top
draw_x = -left draw_x = -left
draw_y = -top 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: else:
# Fallback for PIL versions without baseline-aware textbbox. # Fallback for PIL versions without baseline-aware textbbox.
try: 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: except Exception:
font_size_val = getattr(props, 'font_size', 100) try:
width = font_size_val * len(text_value) * 0.6 font_size_val = int(getattr(props, 'font_size', 100))
height = font_size_val except (ValueError, TypeError):
text_bbox = (0, 0, width, height) font_size_val = 100
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 left, top, right, bottom = text_bbox
text_width = max(1, int(round(right - left))) text_width = max(1, int(round(right - left)))
text_height = max(1, int(round(bottom - top))) text_height = max(1, int(round(bottom - top)))
@@ -163,7 +171,7 @@ def generate_texture_image(props, width, height):
offset_x = -left offset_x = -left
offset_y = -top offset_y = -top
baseline_offset = offset_y 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 content_box = overlay_img.getbbox() if overlay_img else None
crop_left = crop_top = 0 crop_left = crop_top = 0
@@ -234,7 +242,7 @@ def generate_texture_image(props, width, height):
overlay_img = overlay_img.resize(new_size, lanczos_filter) overlay_img = overlay_img.resize(new_size, lanczos_filter)
return overlay_img 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.""" """Composite enabled components according to z-index and placement."""
components_data = [] components_data = []
for index, component in _iter_enabled_components(): for index, component in _iter_enabled_components():
@@ -245,7 +253,7 @@ def generate_texture_image(props, width, height):
continue continue
component_type = getattr(component, 'component_type', 'IMAGE') component_type = getattr(component, 'component_type', 'IMAGE')
if component_type == 'TEXT': 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: else:
overlay_img = _make_image_overlay(component) overlay_img = _make_image_overlay(component)
baseline_offset = overlay_img.height if overlay_img is not None else None baseline_offset = overlay_img.height if overlay_img is not None else None
@@ -376,29 +384,58 @@ def generate_texture_image(props, width, height):
positioned_entries.sort(key=lambda item: item['z']) 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: for entry in positioned_entries:
x_pos, y_pos = entry.get('position', (0, 0)) 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 entry.get('direct_draw') and entry.get('render_info'):
if draw_ctx is None:
draw_ctx = ImageDraw.Draw(pil_canvas)
info = entry['render_info'] info = entry['render_info']
draw_pos = ( draw_pos = (
x_pos + info.get('draw_offset_x', 0), x_pos + info.get('draw_offset_x', 0),
y_pos + info.get('draw_offset_y', 0) y_pos + info.get('draw_offset_y', 0)
) )
anchor = info.get('anchor') 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_ctx.text(
draw_pos, draw_pos,
info.get('text', ''), text_val,
font=info.get('font'), font=text_font,
fill=info.get('color'), fill=info.get('color'),
anchor=anchor anchor=anchor,
**stroke_kwargs
) )
else: else:
overlay_img = entry.get('image') overlay_img = entry.get('image')
if overlay_img is None: if overlay_img is None:
continue 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 = Image.new('RGBA', (width, height), (0, 0, 0, 0))
overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img) overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img)
pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer) pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer)
@@ -417,6 +454,11 @@ def generate_texture_image(props, width, height):
base_text_rect = None base_text_rect = None
final_font_size = props.font_size final_font_size = props.font_size
before_layers_applied = False 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) # Draw text if provided (including prepend/append)
full_text_parts = [] full_text_parts = []
@@ -641,12 +683,9 @@ def generate_texture_image(props, width, height):
'draw_y': float(start_y), 'draw_y': float(start_y),
'ascent': float(base_ascent) 'ascent': float(base_ascent)
} }
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size) # Pre-initialize effect layers and drawing contexts
before_layers_applied = True
from PIL import ImageFilter from PIL import ImageFilter
text_color = tuple(int(c * 255) for c in props.text_color[:4]) text_color = tuple(int(c * 255) for c in props.text_color[:4])
# Create effect layers
has_shadow = getattr(props, 'enable_shadow', False) has_shadow = getattr(props, 'enable_shadow', False)
has_glow = getattr(props, 'enable_glow', False) has_glow = getattr(props, 'enable_glow', False)
has_blur = getattr(props, 'enable_blur', False) has_blur = getattr(props, 'enable_blur', False)
@@ -655,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 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 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 s_draw = ImageDraw.Draw(shadow_layer) if has_shadow else None
g_draw = ImageDraw.Draw(glow_layer) if has_glow 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: try:
s_dx = float(getattr(props, 'shadow_offset_x', 8.0)) if has_shadow else 0.0 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 s_dy = float(getattr(props, 'shadow_offset_y', 8.0)) if has_shadow else 0.0
except (ValueError, TypeError): except (ValueError, TypeError):
s_dx, s_dy = 0.0, 0.0 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 current_y = start_y
for i, part in enumerate(full_text_parts): for i, part in enumerate(full_text_parts):
@@ -749,11 +791,10 @@ def generate_texture_image(props, width, height):
c_width = 0 c_width = 0
if c_type == 'TEXT' and component.text_content.strip(): if c_type == 'TEXT' and component.text_content.strip():
try: # Use the same helper to get the final (possibly cropped/stroked) width
c_bbox = measure_draw.textbbox((0, 0), component.text_content.strip(), font=font, **bbox_stroke_kwargs) temp_img, _, _, meta = _make_text_overlay(component, final_font_size, stroke_kwargs)
c_width = c_bbox[2] - c_bbox[0] if meta:
except Exception: c_width = meta.get('width', 0)
pass
elif c_type == 'IMAGE': elif c_type == 'IMAGE':
resolved_path = getattr(component, 'image_path', '') resolved_path = getattr(component, 'image_path', '')
if resolved_path: if resolved_path:
@@ -791,12 +832,9 @@ def generate_texture_image(props, width, height):
'draw_y': float(y), 'draw_y': float(y),
'ascent': float(base_ascent) 'ascent': float(base_ascent)
} }
pil_img = _apply_component_layers(pil_img, 'before', base_text_rect, final_font_size) # Pre-initialize effect layers and drawing contexts
before_layers_applied = True
from PIL import ImageFilter from PIL import ImageFilter
text_color = tuple(int(c * 255) for c in props.text_color[:4]) text_color = tuple(int(c * 255) for c in props.text_color[:4])
# Create effect layers
has_shadow = getattr(props, 'enable_shadow', False) has_shadow = getattr(props, 'enable_shadow', False)
has_glow = getattr(props, 'enable_glow', False) has_glow = getattr(props, 'enable_glow', False)
has_blur = getattr(props, 'enable_blur', False) has_blur = getattr(props, 'enable_blur', False)
@@ -805,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 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 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) 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: 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) s_draw.text((x + s_dx, y + s_dy), full_text, font=font, fill=s_color, **stroke_kwargs)
if has_glow: 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) 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) main_draw.text((x, y), full_text, font=font, fill=text_color, **stroke_kwargs)
@@ -855,8 +898,8 @@ def generate_texture_image(props, width, height):
pass pass
if not before_layers_applied: 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, '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) 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) # Convert PIL image to Blender format (flip vertically to fix mirroring)
pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue
@@ -903,9 +946,15 @@ def generate_preview(props, preview_width=None, preview_height=None):
pass pass
# Use full texture resolution when not explicitly provided # Use full texture resolution when not explicitly provided
if not preview_width or preview_width <= 0: if not preview_width or preview_width <= 0:
preview_width = getattr(props, 'texture_width', 512) or 512 try:
preview_width = int(getattr(props, 'texture_width', 512))
except (ValueError, TypeError):
preview_width = 512
if not preview_height or preview_height <= 0: if not preview_height or preview_height <= 0:
preview_height = getattr(props, 'texture_height', 512) or 512 try:
preview_height = int(getattr(props, 'texture_height', 512))
except (ValueError, TypeError):
preview_height = 512
# Generate at preview resolution # Generate at preview resolution
result = generate_texture_image(props, preview_width, preview_height) result = generate_texture_image(props, preview_width, preview_height)

View File

@@ -109,6 +109,7 @@ def get_system_fonts():
elif system == "Darwin": # macOS elif system == "Darwin": # macOS
font_dirs = [ font_dirs = [
"/System/Library/Fonts/", "/System/Library/Fonts/",
"/System/Library/Fonts/Supplemental/",
"/Library/Fonts/", "/Library/Fonts/",
os.path.expanduser("~/Library/Fonts/") os.path.expanduser("~/Library/Fonts/")
] ]
@@ -189,6 +190,8 @@ def find_default_truetype_font():
] ]
elif system == "darwin": elif system == "darwin":
preferred_fonts = [ preferred_fonts = [
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Courier New.ttf",
"/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/SFNSDisplay.ttf", "/System/Library/Fonts/SFNSDisplay.ttf",
"/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Helvetica.ttc",