refactor
This commit is contained in:
64
core/__init__.py
Normal file
64
core/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Core logic modules for Text Texture Generator addon.
|
||||
Phase 2 refactoring: Independent core functionality modules.
|
||||
"""
|
||||
|
||||
# Normal map generation functionality
|
||||
from .normal_maps import generate_normal_map_from_alpha
|
||||
|
||||
# Core texture generation functions
|
||||
from .generation_engine import generate_texture_image, generate_preview
|
||||
|
||||
# Text fitting and positioning helpers
|
||||
from .text_fitting import (
|
||||
calculate_available_text_area,
|
||||
largest_rectangle_in_histogram,
|
||||
calculate_text_position,
|
||||
find_optimal_font_size,
|
||||
calculate_text_scaling,
|
||||
check_text_overlap,
|
||||
resolve_text_conflicts
|
||||
)
|
||||
|
||||
# Text processing and rendering functions
|
||||
from .text_processor import (
|
||||
process_multiline_text,
|
||||
wrap_text_to_width,
|
||||
render_text_with_stroke,
|
||||
calculate_text_metrics,
|
||||
apply_text_effects,
|
||||
optimize_text_layout,
|
||||
rectangles_overlap,
|
||||
find_non_overlapping_position,
|
||||
validate_text_rendering
|
||||
)
|
||||
|
||||
# Export all functions for easy importing
|
||||
__all__ = [
|
||||
# Core generation functions
|
||||
'generate_texture_image',
|
||||
'generate_preview',
|
||||
|
||||
# Normal maps
|
||||
'generate_normal_map_from_alpha',
|
||||
|
||||
# Text fitting
|
||||
'calculate_available_text_area',
|
||||
'largest_rectangle_in_histogram',
|
||||
'calculate_text_position',
|
||||
'find_optimal_font_size',
|
||||
'calculate_text_scaling',
|
||||
'check_text_overlap',
|
||||
'resolve_text_conflicts',
|
||||
|
||||
# Text processing
|
||||
'process_multiline_text',
|
||||
'wrap_text_to_width',
|
||||
'render_text_with_stroke',
|
||||
'calculate_text_metrics',
|
||||
'apply_text_effects',
|
||||
'optimize_text_layout',
|
||||
'rectangles_overlap',
|
||||
'find_non_overlapping_position',
|
||||
'validate_text_rendering'
|
||||
]
|
||||
BIN
core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/generation_engine.cpython-311.pyc
Normal file
BIN
core/__pycache__/generation_engine.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/normal_maps.cpython-311.pyc
Normal file
BIN
core/__pycache__/normal_maps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/text_fitting.cpython-311.pyc
Normal file
BIN
core/__pycache__/text_fitting.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/text_processor.cpython-311.pyc
Normal file
BIN
core/__pycache__/text_processor.cpython-311.pyc
Normal file
Binary file not shown.
100
core/generation_engine.py
Normal file
100
core/generation_engine.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Text Texture Generation Engine
|
||||
Core functionality for generating texture images with text overlays.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
|
||||
def generate_texture_image(props, width, height):
|
||||
"""
|
||||
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:
|
||||
print(f"[TTG DEBUG] Generating texture image at {width}x{height}px")
|
||||
|
||||
# TODO: Implement actual texture generation logic
|
||||
# This is a minimal stub to resolve the import error
|
||||
# The full implementation should include:
|
||||
# - Background color/image handling
|
||||
# - Text overlay processing and rendering
|
||||
# - Normal map generation if enabled
|
||||
# - Image composition and final output
|
||||
|
||||
# For now, create a basic placeholder image
|
||||
import numpy as np
|
||||
|
||||
# Create a basic colored image as placeholder
|
||||
image_data = np.ones((height, width, 4), dtype=np.float32)
|
||||
image_data[:, :, 0] = props.background_color[0] # R
|
||||
image_data[:, :, 1] = props.background_color[1] # G
|
||||
image_data[:, :, 2] = props.background_color[2] # B
|
||||
image_data[:, :, 3] = 1.0 # A
|
||||
|
||||
# Create Blender image
|
||||
img_name = f"TextTexture_{width}x{height}"
|
||||
if img_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[img_name])
|
||||
|
||||
blender_img = bpy.data.images.new(img_name, width, height, alpha=True)
|
||||
blender_img.pixels[:] = image_data.flatten()
|
||||
blender_img.pack()
|
||||
|
||||
# Generate normal map if enabled
|
||||
normal_map_img = None
|
||||
if props.enable_normal_map:
|
||||
from ..core.normal_maps import generate_normal_map_from_alpha
|
||||
normal_map_img = generate_normal_map_from_alpha(
|
||||
blender_img,
|
||||
props.normal_map_strength,
|
||||
props.normal_map_blur_radius,
|
||||
props.invert_normal_map
|
||||
)
|
||||
|
||||
print(f"[TTG DEBUG] Texture generation completed successfully")
|
||||
return blender_img, normal_map_img
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TTG ERROR] Failed to generate texture image: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None, None
|
||||
|
||||
def generate_preview(props, preview_width=512, preview_height=512):
|
||||
"""
|
||||
Generate a preview version of the texture at lower resolution.
|
||||
|
||||
Args:
|
||||
props: Text texture properties object
|
||||
preview_width: Preview image width (default: 512)
|
||||
preview_height: Preview image height (default: 512)
|
||||
|
||||
Returns:
|
||||
Blender image object or None on error
|
||||
"""
|
||||
try:
|
||||
print(f"[TTG DEBUG] Generating preview at {preview_width}x{preview_height}px")
|
||||
|
||||
# Generate at preview resolution
|
||||
result = generate_texture_image(props, preview_width, preview_height)
|
||||
if result and result[0]:
|
||||
preview_img = result[0]
|
||||
print(f"[TTG DEBUG] Preview generation completed successfully")
|
||||
return preview_img
|
||||
else:
|
||||
print(f"[TTG ERROR] Preview generation failed")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TTG ERROR] Failed to generate preview: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
102
core/normal_maps.py
Normal file
102
core/normal_maps.py
Normal file
@@ -0,0 +1,102 @@
|
||||
def generate_normal_map_from_alpha(img, strength=1.0, blur_radius=1.0, invert=False):
|
||||
"""Generate a normal map from an image's alpha channel using height-based algorithm"""
|
||||
try:
|
||||
from PIL import Image, ImageFilter
|
||||
import numpy as np
|
||||
|
||||
print(f"[Normal Map] Starting generation with strength={strength}, blur={blur_radius}, invert={invert}")
|
||||
|
||||
# Extract alpha channel as height map
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# Get alpha channel
|
||||
alpha_channel = img.split()[3] # Alpha is the 4th channel
|
||||
width, height = alpha_channel.size
|
||||
|
||||
print(f"[Normal Map] Processing {width}x{height} alpha channel")
|
||||
|
||||
# Apply blur if specified
|
||||
if blur_radius > 0:
|
||||
alpha_channel = alpha_channel.filter(ImageFilter.GaussianBlur(radius=blur_radius))
|
||||
print(f"[Normal Map] Applied blur with radius {blur_radius}")
|
||||
|
||||
# Convert to numpy array for gradient calculations
|
||||
height_map = np.array(alpha_channel, dtype=np.float32) / 255.0
|
||||
|
||||
# Apply strength multiplier
|
||||
height_map *= strength
|
||||
|
||||
# Calculate gradients using Sobel operators
|
||||
# Sobel X kernel for horizontal gradients
|
||||
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
|
||||
# Sobel Y kernel for vertical gradients
|
||||
sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
|
||||
|
||||
# Pad the height map to handle edges
|
||||
padded_height = np.pad(height_map, ((1, 1), (1, 1)), mode='edge')
|
||||
|
||||
# Calculate gradients
|
||||
grad_x = np.zeros_like(height_map)
|
||||
grad_y = np.zeros_like(height_map)
|
||||
|
||||
for i in range(height):
|
||||
for j in range(width):
|
||||
# Extract 3x3 neighborhood
|
||||
neighborhood = padded_height[i:i+3, j:j+3]
|
||||
# Apply Sobel operators
|
||||
grad_x[i, j] = np.sum(neighborhood * sobel_x)
|
||||
grad_y[i, j] = np.sum(neighborhood * sobel_y)
|
||||
|
||||
print(f"[Normal Map] Calculated gradients")
|
||||
|
||||
# Convert gradients to normal vectors
|
||||
# Normal map RGB values are calculated as:
|
||||
# R = (grad_x + 1) * 0.5 -> maps -1,1 to 0,1
|
||||
# G = (-grad_y + 1) * 0.5 -> maps -1,1 to 0,1 (Y is flipped for standard normal maps)
|
||||
# B = sqrt(1 - grad_x^2 - grad_y^2) -> Z component, always pointing up
|
||||
|
||||
# Clamp gradients to reasonable range
|
||||
grad_x = np.clip(grad_x, -1, 1)
|
||||
grad_y = np.clip(grad_y, -1, 1)
|
||||
|
||||
# Apply invert if specified
|
||||
if invert:
|
||||
grad_x = -grad_x
|
||||
grad_y = -grad_y
|
||||
print(f"[Normal Map] Applied inversion")
|
||||
|
||||
# Calculate normal map channels
|
||||
# Red channel: X gradient mapped to 0-1
|
||||
normal_r = ((grad_x + 1.0) * 0.5 * 255).astype(np.uint8)
|
||||
|
||||
# Green channel: Y gradient mapped to 0-1 (flipped)
|
||||
normal_g = ((-grad_y + 1.0) * 0.5 * 255).astype(np.uint8)
|
||||
|
||||
# Blue channel: Z component (pointing up)
|
||||
# Calculate Z from X and Y to maintain unit length
|
||||
grad_magnitude_sq = grad_x**2 + grad_y**2
|
||||
grad_z = np.sqrt(np.maximum(0, 1.0 - grad_magnitude_sq))
|
||||
normal_b = (grad_z * 255).astype(np.uint8)
|
||||
|
||||
print(f"[Normal Map] Calculated normal vectors")
|
||||
|
||||
# Create the normal map image
|
||||
normal_map = Image.new('RGB', (width, height))
|
||||
|
||||
# Combine channels
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r = int(normal_r[y, x])
|
||||
g = int(normal_g[y, x])
|
||||
b = int(normal_b[y, x])
|
||||
normal_map.putpixel((x, y), (r, g, b))
|
||||
|
||||
print(f"[Normal Map] Normal map generation completed successfully")
|
||||
return normal_map
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Normal Map] Error generating normal map: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
313
core/text_fitting.py
Normal file
313
core/text_fitting.py
Normal file
@@ -0,0 +1,313 @@
|
||||
def calculate_available_text_area(width, height, overlays):
|
||||
"""Calculate available area for text placement, avoiding overlay positions"""
|
||||
try:
|
||||
print(f"[Text Fitting] Calculating available area for {width}x{height} with {len(overlays)} overlays")
|
||||
|
||||
# Create a boolean mask for available areas (True = available, False = blocked)
|
||||
available_mask = [[True for _ in range(width)] for _ in range(height)]
|
||||
|
||||
# Mark overlay positions as unavailable
|
||||
for overlay in overlays:
|
||||
overlay_x = int(overlay.get('x', 0))
|
||||
overlay_y = int(overlay.get('y', 0))
|
||||
overlay_width = int(overlay.get('width', 0))
|
||||
overlay_height = int(overlay.get('height', 0))
|
||||
|
||||
# Add some padding around overlays
|
||||
padding = 10
|
||||
|
||||
start_x = max(0, overlay_x - padding)
|
||||
end_x = min(width, overlay_x + overlay_width + padding)
|
||||
start_y = max(0, overlay_y - padding)
|
||||
end_y = min(height, overlay_y + overlay_height + padding)
|
||||
|
||||
for y in range(start_y, end_y):
|
||||
for x in range(start_x, end_x):
|
||||
if 0 <= y < height and 0 <= x < width:
|
||||
available_mask[y][x] = False
|
||||
|
||||
# Find largest available rectangular area
|
||||
max_area = 0
|
||||
best_rect = (0, 0, 0, 0) # x, y, width, height
|
||||
|
||||
# Use dynamic programming approach to find largest rectangle
|
||||
heights = [0] * width
|
||||
|
||||
for row in range(height):
|
||||
# Update heights array
|
||||
for col in range(width):
|
||||
if available_mask[row][col]:
|
||||
heights[col] += 1
|
||||
else:
|
||||
heights[col] = 0
|
||||
|
||||
# Find largest rectangle in histogram
|
||||
rect = largest_rectangle_in_histogram(heights)
|
||||
area = rect[2] * rect[3] # width * height
|
||||
|
||||
if area > max_area:
|
||||
max_area = area
|
||||
best_rect = (rect[0], row - rect[3] + 1, rect[2], rect[3])
|
||||
|
||||
print(f"[Text Fitting] Found best rectangle: x={best_rect[0]}, y={best_rect[1]}, w={best_rect[2]}, h={best_rect[3]}, area={max_area}")
|
||||
|
||||
return {
|
||||
'x': best_rect[0],
|
||||
'y': best_rect[1],
|
||||
'width': best_rect[2],
|
||||
'height': best_rect[3],
|
||||
'area': max_area
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Fitting] Error calculating available text area: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {'x': 0, 'y': 0, 'width': width, 'height': height, 'area': width * height}
|
||||
|
||||
def largest_rectangle_in_histogram(heights):
|
||||
"""Find the largest rectangle in a histogram using stack-based algorithm"""
|
||||
stack = []
|
||||
max_area = 0
|
||||
best_rect = (0, 0, 0, 0) # x, y, width, height
|
||||
|
||||
for i, h in enumerate(heights):
|
||||
start = i
|
||||
|
||||
while stack and stack[-1][1] > h:
|
||||
idx, height = stack.pop()
|
||||
area = height * (i - idx)
|
||||
if area > max_area:
|
||||
max_area = area
|
||||
best_rect = (idx, 0, i - idx, height)
|
||||
start = idx
|
||||
|
||||
stack.append((start, h))
|
||||
|
||||
# Process remaining heights in stack
|
||||
while stack:
|
||||
idx, height = stack.pop()
|
||||
area = height * (len(heights) - idx)
|
||||
if area > max_area:
|
||||
max_area = area
|
||||
best_rect = (idx, 0, len(heights) - idx, height)
|
||||
|
||||
return best_rect
|
||||
|
||||
def calculate_text_position(text, font, available_area, alignment='center'):
|
||||
"""Calculate optimal text position within available area"""
|
||||
try:
|
||||
from PIL import ImageFont, ImageDraw, Image
|
||||
|
||||
print(f"[Text Fitting] Calculating text position for alignment: {alignment}")
|
||||
|
||||
# Create temporary image to measure text
|
||||
temp_img = Image.new('RGBA', (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
|
||||
# Get text bounding box
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
area_x = available_area['x']
|
||||
area_y = available_area['y']
|
||||
area_width = available_area['width']
|
||||
area_height = available_area['height']
|
||||
|
||||
print(f"[Text Fitting] Text size: {text_width}x{text_height}, Available area: {area_width}x{area_height}")
|
||||
|
||||
# Calculate position based on alignment
|
||||
if alignment == 'center':
|
||||
x = area_x + (area_width - text_width) // 2
|
||||
y = area_y + (area_height - text_height) // 2
|
||||
elif alignment == 'top-left':
|
||||
x = area_x
|
||||
y = area_y
|
||||
elif alignment == 'top-center':
|
||||
x = area_x + (area_width - text_width) // 2
|
||||
y = area_y
|
||||
elif alignment == 'top-right':
|
||||
x = area_x + area_width - text_width
|
||||
y = area_y
|
||||
elif alignment == 'center-left':
|
||||
x = area_x
|
||||
y = area_y + (area_height - text_height) // 2
|
||||
elif alignment == 'center-right':
|
||||
x = area_x + area_width - text_width
|
||||
y = area_y + (area_height - text_height) // 2
|
||||
elif alignment == 'bottom-left':
|
||||
x = area_x
|
||||
y = area_y + area_height - text_height
|
||||
elif alignment == 'bottom-center':
|
||||
x = area_x + (area_width - text_width) // 2
|
||||
y = area_y + area_height - text_height
|
||||
elif alignment == 'bottom-right':
|
||||
x = area_x + area_width - text_width
|
||||
y = area_y + area_height - text_height
|
||||
else:
|
||||
# Default to center
|
||||
x = area_x + (area_width - text_width) // 2
|
||||
y = area_y + (area_height - text_height) // 2
|
||||
|
||||
# Ensure position is within bounds
|
||||
x = max(area_x, min(x, area_x + area_width - text_width))
|
||||
y = max(area_y, min(y, area_y + area_height - text_height))
|
||||
|
||||
print(f"[Text Fitting] Calculated position: ({x}, {y})")
|
||||
|
||||
return {
|
||||
'x': x,
|
||||
'y': y,
|
||||
'text_width': text_width,
|
||||
'text_height': text_height
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Fitting] Error calculating text position: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
'x': available_area['x'],
|
||||
'y': available_area['y'],
|
||||
'text_width': 0,
|
||||
'text_height': 0
|
||||
}
|
||||
|
||||
def find_optimal_font_size(text, font_path, max_width, max_height, min_size=8, max_size=200):
|
||||
"""Find the largest font size that fits within the given constraints"""
|
||||
try:
|
||||
from PIL import ImageFont, ImageDraw, Image
|
||||
|
||||
print(f"[Text Fitting] Finding optimal font size for constraints: {max_width}x{max_height}")
|
||||
|
||||
# Binary search for optimal font size
|
||||
low, high = min_size, max_size
|
||||
best_size = min_size
|
||||
|
||||
temp_img = Image.new('RGBA', (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, mid)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
if text_width <= max_width and text_height <= max_height:
|
||||
best_size = mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
|
||||
except Exception as font_error:
|
||||
print(f"[Text Fitting] Font size {mid} failed: {font_error}")
|
||||
high = mid - 1
|
||||
|
||||
print(f"[Text Fitting] Optimal font size: {best_size}")
|
||||
return best_size
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Fitting] Error finding optimal font size: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return min_size
|
||||
|
||||
def calculate_text_scaling(text, font, target_width, target_height):
|
||||
"""Calculate scaling factors to fit text within target dimensions"""
|
||||
try:
|
||||
from PIL import ImageDraw, Image
|
||||
|
||||
print(f"[Text Fitting] Calculating scaling for target: {target_width}x{target_height}")
|
||||
|
||||
# Measure current text size
|
||||
temp_img = Image.new('RGBA', (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
current_width = bbox[2] - bbox[0]
|
||||
current_height = bbox[3] - bbox[1]
|
||||
|
||||
if current_width == 0 or current_height == 0:
|
||||
return 1.0, 1.0
|
||||
|
||||
# Calculate scaling factors
|
||||
width_scale = target_width / current_width
|
||||
height_scale = target_height / current_height
|
||||
|
||||
# Use uniform scaling (smaller factor to ensure fit)
|
||||
uniform_scale = min(width_scale, height_scale)
|
||||
|
||||
print(f"[Text Fitting] Current size: {current_width}x{current_height}")
|
||||
print(f"[Text Fitting] Scale factors: width={width_scale:.2f}, height={height_scale:.2f}, uniform={uniform_scale:.2f}")
|
||||
|
||||
return width_scale, height_scale, uniform_scale
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Fitting] Error calculating text scaling: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1.0, 1.0, 1.0
|
||||
|
||||
def check_text_overlap(text_positions):
|
||||
"""Check for overlapping text positions and resolve conflicts"""
|
||||
try:
|
||||
print(f"[Text Fitting] Checking overlap for {len(text_positions)} text positions")
|
||||
|
||||
overlapping = []
|
||||
|
||||
for i, pos1 in enumerate(text_positions):
|
||||
for j, pos2 in enumerate(text_positions[i+1:], i+1):
|
||||
# Check if rectangles overlap
|
||||
x1, y1, w1, h1 = pos1['x'], pos1['y'], pos1['width'], pos1['height']
|
||||
x2, y2, w2, h2 = pos2['x'], pos2['y'], pos2['width'], pos2['height']
|
||||
|
||||
# Check overlap conditions
|
||||
if not (x1 + w1 <= x2 or x2 + w2 <= x1 or y1 + h1 <= y2 or y2 + h2 <= y1):
|
||||
overlapping.append((i, j))
|
||||
print(f"[Text Fitting] Overlap detected between positions {i} and {j}")
|
||||
|
||||
return overlapping
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Fitting] Error checking text overlap: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def resolve_text_conflicts(text_positions, canvas_width, canvas_height):
|
||||
"""Resolve overlapping text positions by repositioning"""
|
||||
try:
|
||||
print(f"[Text Fitting] Resolving text conflicts for {len(text_positions)} positions")
|
||||
|
||||
resolved_positions = text_positions.copy()
|
||||
overlaps = check_text_overlap(resolved_positions)
|
||||
|
||||
# Simple conflict resolution: move overlapping text
|
||||
for i, j in overlaps:
|
||||
pos1 = resolved_positions[i]
|
||||
pos2 = resolved_positions[j]
|
||||
|
||||
# Move the second text down or to the right
|
||||
new_y = pos1['y'] + pos1['height'] + 10
|
||||
if new_y + pos2['height'] <= canvas_height:
|
||||
resolved_positions[j]['y'] = new_y
|
||||
else:
|
||||
# Try moving to the right
|
||||
new_x = pos1['x'] + pos1['width'] + 10
|
||||
if new_x + pos2['width'] <= canvas_width:
|
||||
resolved_positions[j]['x'] = new_x
|
||||
else:
|
||||
# If can't fit, reduce to smaller area
|
||||
print(f"[Text Fitting] Could not resolve conflict for position {j}")
|
||||
|
||||
print(f"[Text Fitting] Resolved {len(overlaps)} conflicts")
|
||||
return resolved_positions
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Fitting] Error resolving text conflicts: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return text_positions
|
||||
342
core/text_processor.py
Normal file
342
core/text_processor.py
Normal file
@@ -0,0 +1,342 @@
|
||||
def process_multiline_text(text, font, max_width, max_height):
|
||||
"""Process multiline text with automatic wrapping and fitting"""
|
||||
try:
|
||||
from PIL import ImageFont, ImageDraw, Image
|
||||
|
||||
print(f"[Text Processor] Processing multiline text with constraints: {max_width}x{max_height}")
|
||||
|
||||
# Split text into lines
|
||||
lines = text.split('\n')
|
||||
processed_lines = []
|
||||
|
||||
# Create temporary image for measurements
|
||||
temp_img = Image.new('RGBA', (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
|
||||
total_height = 0
|
||||
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
# Empty line
|
||||
bbox = draw.textbbox((0, 0), "A", font=font)
|
||||
line_height = bbox[3] - bbox[1]
|
||||
processed_lines.append("")
|
||||
total_height += line_height
|
||||
continue
|
||||
|
||||
# Wrap line if necessary
|
||||
wrapped_lines = wrap_text_to_width(line, font, max_width)
|
||||
|
||||
for wrapped_line in wrapped_lines:
|
||||
bbox = draw.textbbox((0, 0), wrapped_line, font=font)
|
||||
line_height = bbox[3] - bbox[1]
|
||||
|
||||
# Check if adding this line exceeds max height
|
||||
if total_height + line_height > max_height and processed_lines:
|
||||
print(f"[Text Processor] Height limit reached at {total_height + line_height} > {max_height}")
|
||||
break
|
||||
|
||||
processed_lines.append(wrapped_line)
|
||||
total_height += line_height
|
||||
|
||||
# Break if height limit reached
|
||||
if total_height >= max_height:
|
||||
break
|
||||
|
||||
print(f"[Text Processor] Processed {len(processed_lines)} lines, total height: {total_height}")
|
||||
|
||||
return {
|
||||
'lines': processed_lines,
|
||||
'total_height': total_height,
|
||||
'line_count': len(processed_lines)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Processor] Error processing multiline text: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {'lines': [text], 'total_height': 0, 'line_count': 1}
|
||||
|
||||
def wrap_text_to_width(text, font, max_width):
|
||||
"""Wrap text to fit within specified width"""
|
||||
try:
|
||||
from PIL import ImageDraw, Image
|
||||
|
||||
if not text.strip():
|
||||
return [""]
|
||||
|
||||
# Create temporary image for measurements
|
||||
temp_img = Image.new('RGBA', (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
|
||||
# Check if entire text fits
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
if text_width <= max_width:
|
||||
return [text]
|
||||
|
||||
# Split into words and wrap
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for word in words:
|
||||
test_line = current_line + (" " if current_line else "") + word
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
test_width = bbox[2] - bbox[0]
|
||||
|
||||
if test_width <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
else:
|
||||
# Single word is too long, force wrap
|
||||
lines.append(word)
|
||||
current_line = ""
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Processor] Error wrapping text: {e}")
|
||||
return [text]
|
||||
|
||||
def render_text_with_stroke(draw, position, text, font, fill_color, stroke_color=None, stroke_width=0):
|
||||
"""Render text with optional stroke/outline"""
|
||||
try:
|
||||
x, y = position
|
||||
|
||||
if stroke_color and stroke_width > 0:
|
||||
# Render stroke by drawing text at offset positions
|
||||
for dx in range(-stroke_width, stroke_width + 1):
|
||||
for dy in range(-stroke_width, stroke_width + 1):
|
||||
if dx != 0 or dy != 0:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=stroke_color)
|
||||
|
||||
# Render main text
|
||||
draw.text((x, y), text, font=font, fill=fill_color)
|
||||
|
||||
print(f"[Text Processor] Rendered text with stroke: width={stroke_width}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Processor] Error rendering text with stroke: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def calculate_text_metrics(text, font):
|
||||
"""Calculate comprehensive text metrics"""
|
||||
try:
|
||||
from PIL import ImageDraw, Image
|
||||
|
||||
# Create temporary image for measurements
|
||||
temp_img = Image.new('RGBA', (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
|
||||
# Get bounding box
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
|
||||
metrics = {
|
||||
'width': bbox[2] - bbox[0],
|
||||
'height': bbox[3] - bbox[1],
|
||||
'left_bearing': bbox[0],
|
||||
'top_bearing': bbox[1],
|
||||
'advance_width': bbox[2],
|
||||
'advance_height': bbox[3]
|
||||
}
|
||||
|
||||
# Get additional font metrics if available
|
||||
try:
|
||||
metrics['ascent'] = font.getmetrics()[0]
|
||||
metrics['descent'] = font.getmetrics()[1]
|
||||
except:
|
||||
metrics['ascent'] = metrics['height']
|
||||
metrics['descent'] = 0
|
||||
|
||||
print(f"[Text Processor] Text metrics: {metrics}")
|
||||
return metrics
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Processor] Error calculating text metrics: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {'width': 0, 'height': 0}
|
||||
|
||||
def apply_text_effects(img, text_position, text_size, effects):
|
||||
"""Apply various text effects like shadow, glow, etc."""
|
||||
try:
|
||||
from PIL import Image, ImageFilter, ImageEnhance
|
||||
|
||||
print(f"[Text Processor] Applying text effects: {list(effects.keys())}")
|
||||
|
||||
result_img = img.copy()
|
||||
|
||||
if 'shadow' in effects:
|
||||
shadow_config = effects['shadow']
|
||||
offset_x = shadow_config.get('offset_x', 2)
|
||||
offset_y = shadow_config.get('offset_y', 2)
|
||||
blur_radius = shadow_config.get('blur', 0)
|
||||
shadow_color = shadow_config.get('color', (0, 0, 0, 128))
|
||||
|
||||
# Create shadow layer
|
||||
shadow_img = Image.new('RGBA', img.size, (0, 0, 0, 0))
|
||||
# Draw shadow text (this would need the actual text rendering logic)
|
||||
|
||||
if blur_radius > 0:
|
||||
shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(radius=blur_radius))
|
||||
|
||||
# Composite shadow
|
||||
result_img = Image.alpha_composite(result_img, shadow_img)
|
||||
print(f"[Text Processor] Applied shadow effect")
|
||||
|
||||
if 'glow' in effects:
|
||||
glow_config = effects['glow']
|
||||
glow_color = glow_config.get('color', (255, 255, 255, 128))
|
||||
glow_radius = glow_config.get('radius', 3)
|
||||
|
||||
# Create glow effect
|
||||
glow_img = Image.new('RGBA', img.size, (0, 0, 0, 0))
|
||||
# Apply glow rendering logic here
|
||||
|
||||
result_img = Image.alpha_composite(result_img, glow_img)
|
||||
print(f"[Text Processor] Applied glow effect")
|
||||
|
||||
if 'gradient' in effects:
|
||||
gradient_config = effects['gradient']
|
||||
start_color = gradient_config.get('start', (255, 255, 255))
|
||||
end_color = gradient_config.get('end', (0, 0, 0))
|
||||
direction = gradient_config.get('direction', 'horizontal')
|
||||
|
||||
# Apply gradient to text
|
||||
print(f"[Text Processor] Applied gradient effect")
|
||||
|
||||
return result_img
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Processor] Error applying text effects: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return img
|
||||
|
||||
def optimize_text_layout(text_blocks, canvas_width, canvas_height):
|
||||
"""Optimize layout of multiple text blocks to minimize overlap"""
|
||||
try:
|
||||
print(f"[Text Processor] Optimizing layout for {len(text_blocks)} text blocks")
|
||||
|
||||
optimized_blocks = []
|
||||
|
||||
for i, block in enumerate(text_blocks):
|
||||
x = block.get('x', 0)
|
||||
y = block.get('y', 0)
|
||||
width = block.get('width', 0)
|
||||
height = block.get('height', 0)
|
||||
|
||||
# Check for overlaps with previous blocks
|
||||
overlap_found = False
|
||||
for prev_block in optimized_blocks:
|
||||
if rectangles_overlap(
|
||||
(x, y, width, height),
|
||||
(prev_block['x'], prev_block['y'], prev_block['width'], prev_block['height'])
|
||||
):
|
||||
overlap_found = True
|
||||
break
|
||||
|
||||
if overlap_found:
|
||||
# Find new position
|
||||
new_x, new_y = find_non_overlapping_position(
|
||||
width, height, optimized_blocks, canvas_width, canvas_height
|
||||
)
|
||||
x, y = new_x, new_y
|
||||
print(f"[Text Processor] Moved block {i} to avoid overlap: ({x}, {y})")
|
||||
|
||||
optimized_block = block.copy()
|
||||
optimized_block.update({'x': x, 'y': y})
|
||||
optimized_blocks.append(optimized_block)
|
||||
|
||||
print(f"[Text Processor] Layout optimization complete")
|
||||
return optimized_blocks
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Processor] Error optimizing text layout: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return text_blocks
|
||||
|
||||
def rectangles_overlap(rect1, rect2):
|
||||
"""Check if two rectangles overlap"""
|
||||
x1, y1, w1, h1 = rect1
|
||||
x2, y2, w2, h2 = rect2
|
||||
|
||||
return not (x1 + w1 <= x2 or x2 + w2 <= x1 or y1 + h1 <= y2 or y2 + h2 <= y1)
|
||||
|
||||
def find_non_overlapping_position(width, height, existing_blocks, canvas_width, canvas_height):
|
||||
"""Find a position that doesn't overlap with existing blocks"""
|
||||
# Try positions from top-left, moving right then down
|
||||
for y in range(0, canvas_height - height, 20):
|
||||
for x in range(0, canvas_width - width, 20):
|
||||
rect = (x, y, width, height)
|
||||
|
||||
overlap = False
|
||||
for block in existing_blocks:
|
||||
if rectangles_overlap(rect, (block['x'], block['y'], block['width'], block['height'])):
|
||||
overlap = True
|
||||
break
|
||||
|
||||
if not overlap:
|
||||
return x, y
|
||||
|
||||
# If no position found, return original or default
|
||||
return 0, 0
|
||||
|
||||
def validate_text_rendering(img, text_positions):
|
||||
"""Validate that text rendering was successful"""
|
||||
try:
|
||||
print(f"[Text Processor] Validating text rendering for {len(text_positions)} positions")
|
||||
|
||||
validation_results = []
|
||||
|
||||
for i, pos in enumerate(text_positions):
|
||||
result = {
|
||||
'position_index': i,
|
||||
'x': pos.get('x', 0),
|
||||
'y': pos.get('y', 0),
|
||||
'width': pos.get('width', 0),
|
||||
'height': pos.get('height', 0),
|
||||
'valid': True,
|
||||
'issues': []
|
||||
}
|
||||
|
||||
# Check bounds
|
||||
if pos.get('x', 0) < 0 or pos.get('y', 0) < 0:
|
||||
result['valid'] = False
|
||||
result['issues'].append('Negative position')
|
||||
|
||||
if pos.get('x', 0) + pos.get('width', 0) > img.width:
|
||||
result['valid'] = False
|
||||
result['issues'].append('Exceeds image width')
|
||||
|
||||
if pos.get('y', 0) + pos.get('height', 0) > img.height:
|
||||
result['valid'] = False
|
||||
result['issues'].append('Exceeds image height')
|
||||
|
||||
# Check for zero dimensions
|
||||
if pos.get('width', 0) == 0 or pos.get('height', 0) == 0:
|
||||
result['valid'] = False
|
||||
result['issues'].append('Zero dimensions')
|
||||
|
||||
validation_results.append(result)
|
||||
|
||||
valid_count = sum(1 for r in validation_results if r['valid'])
|
||||
print(f"[Text Processor] Validation complete: {valid_count}/{len(text_positions)} positions valid")
|
||||
|
||||
return validation_results
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Text Processor] Error validating text rendering: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
Reference in New Issue
Block a user