This commit is contained in:
2025-10-13 16:56:38 +02:00
parent bf636cbc66
commit 57b55b10a2
21 changed files with 624 additions and 202 deletions

View File

@@ -53,16 +53,16 @@ def generate_texture_image(props, width, height):
bicubic_filter = getattr(resampling_module, 'BICUBIC', getattr(Image, 'BICUBIC', Image.BILINEAR))
def _iter_enabled_components():
"""Yield enabled composition components."""
"""Yield enabled composition components with their index."""
overlays = getattr(props, 'image_overlays', [])
try:
overlay_list = list(overlays)
except TypeError:
overlay_list = []
for overlay in overlay_list:
for index, overlay in enumerate(overlay_list):
if not getattr(overlay, 'enabled', True):
continue
yield overlay
yield index, overlay
def _calculate_overlay_position(overlay, overlay_size):
"""Calculate top-left pixel position for an overlay image."""
@@ -91,102 +91,305 @@ def generate_texture_image(props, width, height):
x = max(0, min(max_x, x))
y = max(0, min(max_y, y))
return x, y
def _make_text_overlay(component, base_font_size):
"""Return (image, baseline_offset, descent, render_meta) for a text component."""
text_value = getattr(component, 'text_content', '').strip()
if not text_value:
return None, None, None, None
scale = getattr(component, 'scale', 1.0)
try:
scale = float(scale if scale is not None else 1.0)
except (TypeError, ValueError):
scale = 1.0
if not math.isfinite(scale) or scale <= 0:
return None, None, None, None
desired_size = max(4, int(round(max(base_font_size, 4) * scale)))
text_font = load_font_for_size(desired_size)
def _apply_composition_components(pil_canvas):
"""Composite enabled components (text or image) onto the base canvas."""
components = sorted(
_iter_enabled_components(),
key=lambda comp: getattr(comp, 'z_index', 0)
)
temp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
for component in components:
if getattr(component, 'component_type', 'IMAGE') == 'TEXT':
text_value = getattr(component, 'text_content', '').strip()
if not text_value:
overlay_img = None
baseline_offset = None
component_descent = None
render_meta = None
# Prefer anchoring to the left baseline so we can align with the main text baseline.
try:
baseline_bbox = temp_draw.textbbox(
(0, 0),
text_value,
font=text_font,
anchor='ls'
)
except Exception:
baseline_bbox = None
if baseline_bbox:
left, top, right, bottom = baseline_bbox
text_width = max(1, int(round(right - left)))
text_height = max(1, int(round(bottom - top)))
overlay_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay_img)
text_color = tuple(int(c * 255) for c in props.text_color[:4])
# Place the baseline at a consistent offset from the top of the overlay.
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')
else:
# Fallback for PIL versions without baseline-aware textbbox.
try:
text_bbox = temp_draw.textbbox((0, 0), text_value, font=text_font)
except Exception:
width, height = temp_draw.textsize(text_value, font=text_font)
text_bbox = (0, 0, width, height)
left, top, right, bottom = text_bbox
text_width = max(1, int(round(right - left)))
text_height = max(1, int(round(bottom - top)))
overlay_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay_img)
text_color = tuple(int(c * 255) for c in props.text_color[:4])
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)
content_box = overlay_img.getbbox() if overlay_img else None
crop_left = crop_top = 0
if content_box:
crop_left, crop_top, crop_right, crop_bottom = content_box
overlay_img = overlay_img.crop(content_box)
if baseline_offset is not None:
baseline_offset -= crop_top
component_descent = (crop_bottom - crop_top) - (baseline_offset or 0)
else:
component_descent = (overlay_img.height if overlay_img else 0) - (baseline_offset or 0)
if overlay_img and (baseline_offset is None or baseline_offset < 0 or baseline_offset > overlay_img.height):
try:
ascent, descent = text_font.getmetrics()
except Exception:
ascent, descent = overlay_img.height, 0
baseline_offset = min(max(0, int(round(ascent))), overlay_img.height)
component_descent = max(0, overlay_img.height - baseline_offset)
baseline_offset = max(0, int(round(baseline_offset or 0)))
component_descent = max(0, int(round(component_descent or 0)))
render_meta = {
'text': text_value,
'font': text_font,
'color': tuple(int(c * 255) for c in props.text_color[:4]),
'draw_offset_x': (draw_x - crop_left) if baseline_bbox else (offset_x - crop_left),
'draw_offset_y': (draw_y - crop_top) if baseline_bbox else (offset_y - crop_top),
'anchor': 'ls' if baseline_bbox else None,
'width': overlay_img.width if overlay_img else 0,
'height': overlay_img.height if overlay_img else 0
}
return overlay_img, baseline_offset, component_descent, render_meta
def _make_image_overlay(component):
"""Return RGBA image for an image component or None."""
resolved_path = getattr(component, 'image_path', '')
if not resolved_path:
return None
scale = getattr(component, 'scale', 1.0)
try:
scale = float(scale if scale is not None else 1.0)
except (TypeError, ValueError):
scale = 1.0
if not math.isfinite(scale) or scale <= 0:
return None
scale_to_apply = scale
if is_svg_path(resolved_path):
try:
raster_info = ensure_svg_raster(resolved_path, scale)
resolved_path = raster_info.path
scale_to_apply = raster_info.applied_scale
except SVGConversionError as svg_error:
print(f"WARNING: Failed to rasterize SVG overlay '{component.image_path}': {svg_error}")
return None
try:
with Image.open(resolved_path) as raw_overlay:
overlay_img = raw_overlay.convert('RGBA')
except Exception as overlay_error:
print(f"WARNING: Failed to load overlay image '{resolved_path}': {overlay_error}")
return None
if scale_to_apply != 1.0:
new_size = (
max(1, int(round(overlay_img.width * scale_to_apply))),
max(1, int(round(overlay_img.height * scale_to_apply)))
)
if new_size != overlay_img.size:
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):
"""Composite enabled components according to z-index and placement."""
components_data = []
for index, component in _iter_enabled_components():
z_value = getattr(component, 'z_index', 0)
if stage == 'before' and z_value >= 0:
continue
if stage == 'after' and z_value < 0:
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)
else:
overlay_img = _make_image_overlay(component)
baseline_offset = overlay_img.height if overlay_img is not None else None
component_descent = overlay_img.height if overlay_img is not None else None
render_meta = None
if overlay_img is None and not render_meta:
continue
rotation = float(getattr(component, 'rotation', 0.0) or 0.0)
direct_draw = False
width_from_image = overlay_img.width if overlay_img is not None else 0
height_from_image = overlay_img.height if overlay_img is not None else 0
if component_type == 'TEXT' and render_meta and not (rotation % 360):
direct_draw = True
else:
if overlay_img is None:
continue
scale = getattr(component, 'scale', 1.0)
try:
scale = float(scale if scale is not None else 1.0)
except (TypeError, ValueError):
scale = 1.0
text_size = max(8, int(round(props.font_size * max(scale, 0.1))))
text_font = load_font_for_size(text_size)
try:
text_bbox = text_font.getbbox(text_value)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
except AttributeError:
try:
text_width, text_height = text_font.getsize(text_value)
except AttributeError:
# Fallback to a temporary draw context
tmp_draw = ImageDraw.Draw(Image.new('RGBA', (1, 1)))
text_width, text_height = tmp_draw.textsize(text_value, font=text_font)
overlay_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0))
overlay_draw = ImageDraw.Draw(overlay_img)
text_color = tuple(int(c * 255) for c in props.text_color[:4])
overlay_draw.text((0, 0), text_value, font=text_font, fill=text_color)
rotation = float(getattr(component, 'rotation', 0.0) or 0.0)
if rotation % 360:
overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter)
width_from_image = overlay_img.width
height_from_image = overlay_img.height
width_value = 0
height_value = 0
if component_type == 'TEXT' and render_meta:
if direct_draw:
width_value = render_meta.get('width', width_from_image)
height_value = render_meta.get('height', height_from_image)
else:
width_value = width_from_image
height_value = height_from_image
elif overlay_img is not None:
width_value = width_from_image
height_value = height_from_image
components_data.append({
'index': index,
'component': component,
'image': overlay_img,
'z': z_value,
'type': component_type,
'baseline_offset': baseline_offset,
'descent': component_descent,
'render_info': render_meta,
'direct_draw': direct_draw,
'rotation': rotation,
'width': width_value,
'height': height_value
})
if not components_data:
return pil_canvas
sequential_groups = {'PREPEND': [], 'APPEND': []}
absolute_entries = []
for entry in components_data:
placement = getattr(entry['component'], 'placement_mode', 'ABSOLUTE') or 'ABSOLUTE'
if placement in sequential_groups:
sequential_groups[placement].append(entry)
else:
x_pos, y_pos = _calculate_overlay_position(entry['component'], entry['image'].size)
entry['position'] = (x_pos, y_pos)
absolute_entries.append(entry)
base_center_x = width * 0.5
base_center_y = height * 0.5
base_left = base_center_x
base_right = base_center_x
base_top = base_center_y - (base_font_size / 2.0)
base_bottom = base_center_y + (base_font_size / 2.0)
base_baseline = base_bottom
if base_text_rect:
base_center_x = base_text_rect['center_x']
base_center_y = base_text_rect['center_y']
base_left = base_text_rect['left']
base_right = base_text_rect['right']
base_top = base_text_rect.get('top', base_top)
base_bottom = base_text_rect.get('bottom', base_bottom)
base_baseline = base_text_rect.get('baseline', base_bottom)
else:
base_top = max(0.0, min(float(height - base_font_size), base_top))
base_bottom = max(base_top + 1.0, min(float(height), base_bottom))
base_baseline = base_bottom
def assign_sequential_positions(entries, placement):
if not entries:
return []
sorted_entries = sorted(entries, key=lambda item: item['index'])
cursor = base_left if placement == 'PREPEND' else base_right
assigned = []
for entry in sorted_entries:
component = entry['component']
entry_width = entry.get('width', entry['image'].width if entry['image'] else 0)
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
x_pos, y_pos = _calculate_overlay_position(component, overlay_img.size)
if placement == 'PREPEND':
cursor -= spacing_pixels
cursor -= entry_width
x_pos = cursor
cursor -= inter_pixels
else:
cursor += spacing_pixels
x_pos = cursor
cursor += entry_width
cursor += inter_pixels
if base_text_rect:
if entry['type'] == 'TEXT' and entry['baseline_offset'] is not None:
y_pos = int(round(base_baseline - entry['baseline_offset']))
else:
y_pos = int(round(base_bottom - entry_height))
else:
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))
entry['position'] = (x_pos, y_pos)
assigned.append(entry)
return assigned
positioned_entries = absolute_entries
positioned_entries.extend(assign_sequential_positions(sequential_groups['PREPEND'], 'PREPEND'))
positioned_entries.extend(assign_sequential_positions(sequential_groups['APPEND'], 'APPEND'))
positioned_entries.sort(key=lambda item: item['z'])
draw_ctx = None
for entry in positioned_entries:
x_pos, y_pos = entry.get('position', (0, 0))
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')
draw_ctx.text(
draw_pos,
info.get('text', ''),
font=info.get('font'),
fill=info.get('color'),
anchor=anchor
)
else:
overlay_img = entry.get('image')
if overlay_img is None:
continue
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)
continue
resolved_path = getattr(component, 'image_path', '')
if not resolved_path:
continue
scale = getattr(component, 'scale', 1.0)
try:
scale = float(scale if scale is not None else 1.0)
except (TypeError, ValueError):
scale = 1.0
if not math.isfinite(scale) or scale <= 0:
continue
scale_to_apply = scale
if is_svg_path(resolved_path):
try:
raster_info = ensure_svg_raster(resolved_path, scale)
resolved_path = raster_info.path
scale_to_apply = raster_info.applied_scale
except SVGConversionError as svg_error:
print(f"WARNING: Failed to rasterize SVG overlay '{component.image_path}': {svg_error}")
continue
try:
with Image.open(resolved_path) as raw_overlay:
overlay_img = raw_overlay.convert('RGBA')
except Exception as overlay_error:
print(f"WARNING: Failed to load overlay image '{resolved_path}': {overlay_error}")
continue
if scale_to_apply != 1.0:
new_size = (
max(1, int(round(overlay_img.width * scale_to_apply))),
max(1, int(round(overlay_img.height * scale_to_apply)))
)
if new_size != overlay_img.size:
overlay_img = overlay_img.resize(new_size, lanczos_filter)
rotation = float(getattr(component, 'rotation', 0.0) or 0.0)
if rotation % 360:
overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter)
x_pos, y_pos = _calculate_overlay_position(component, overlay_img.size)
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)
return pil_canvas
# Create PIL image with transparent background
@@ -198,6 +401,10 @@ def generate_texture_image(props, width, height):
bg_color = tuple(int(c * 255) for c in props.background_color[:3]) + (255,)
pil_img = Image.new('RGBA', (width, height), bg_color)
base_text_rect = None
final_font_size = props.font_size
before_layers_applied = False
# Draw text if provided (including prepend/append)
full_text_parts = []
if props.prepend_text.strip():
@@ -212,7 +419,7 @@ def generate_texture_image(props, width, height):
if full_text_parts:
pass
draw = ImageDraw.Draw(pil_img)
measure_draw = ImageDraw.Draw(pil_img)
# Combine text based on layout mode
if props.prepend_append_layout == 'HORIZONTAL':
@@ -304,12 +511,12 @@ def generate_texture_image(props, width, height):
test_font = load_font_for_size(test_size)
try:
bbox = draw.textbbox((0, 0), full_text, font=test_font)
bbox = measure_draw.textbbox((0, 0), full_text, font=test_font)
test_width = bbox[2] - bbox[0]
test_height = bbox[3] - bbox[1]
except (ValueError, AttributeError):
# Fallback for bitmap fonts
test_width, test_height = draw.textsize(full_text, font=test_font)
test_width, test_height = measure_draw.textsize(full_text, font=test_font)
if test_width <= target_width and test_height <= target_height:
pass
@@ -323,7 +530,12 @@ def generate_texture_image(props, width, height):
pass
font_size = props.font_size
final_font_size = font_size
font = load_font_for_size(font_size)
try:
base_ascent, base_descent = font.getmetrics()
except Exception:
base_ascent, base_descent = (final_font_size, 0)
# Calculate text position and draw
if layout_mode == "vertical" and props.prepend_append_layout == 'VERTICAL':
@@ -331,18 +543,25 @@ def generate_texture_image(props, width, height):
# Draw each part separately for vertical layout
y_offset = 0
total_height = 0
part_widths = []
part_baselines = []
# Calculate total height first
part_heights = []
for part in full_text_parts:
pass
try:
bbox = draw.textbbox((0, 0), part, font=font)
bbox = measure_draw.textbbox((0, 0), part, font=font)
part_height = bbox[3] - bbox[1]
part_width = bbox[2] - bbox[0]
baseline = base_ascent
except (ValueError, AttributeError):
# Fallback for bitmap fonts
_, part_height = draw.textsize(part, font=font)
part_width, part_height = measure_draw.textsize(part, font=font)
baseline = base_ascent
part_heights.append(part_height)
part_widths.append(part_width)
part_baselines.append(baseline)
total_height += part_height
# Add spacing between parts
@@ -353,6 +572,23 @@ def generate_texture_image(props, width, height):
# Center vertically
start_y = (height - total_height) // 2
max_width = max(part_widths) if part_widths else 0
left_x = (width - max_width) // 2
base_text_rect = {
'left': float(left_x),
'right': float(left_x + max_width),
'top': float(start_y),
'bottom': float(start_y + total_height),
'center_x': float(left_x + max_width / 2.0),
'center_y': float(start_y + total_height / 2.0),
'baseline': float(start_y + base_ascent),
'descent': float(base_descent),
'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
draw = ImageDraw.Draw(pil_img)
# Draw each part
current_y = start_y
@@ -360,12 +596,7 @@ def generate_texture_image(props, width, height):
for i, part in enumerate(full_text_parts):
pass
try:
bbox = draw.textbbox((0, 0), part, font=font)
part_width = bbox[2] - bbox[0]
except (ValueError, AttributeError):
# Fallback for bitmap fonts
part_width, _ = draw.textsize(part, font=font)
part_width = part_widths[i]
x = (width - part_width) // 2 # Center horizontally
draw.text((x, current_y), part, font=font, fill=text_color)
@@ -377,17 +608,47 @@ def generate_texture_image(props, width, height):
else:
pass
# Horizontal layout or single text
left_offset = 0
right_offset = 0
top_offset = 0
bottom_offset = 0
try:
text_bbox = draw.textbbox((0, 0), full_text, font=font)
text_bbox = measure_draw.textbbox((0, 0), full_text, font=font)
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):
# Fallback for bitmap fonts that don't support textbbox
text_width, text_height = draw.textsize(full_text, font=font)
text_width, text_height = measure_draw.textsize(full_text, font=font)
right_offset = text_width
bottom_offset = text_height
# Center text
x = (width - text_width) // 2
y = (height - text_height) // 2
left = x + left_offset
right = x + right_offset
top = y + top_offset
bottom = y + bottom_offset
base_text_rect = {
'left': float(left),
'right': float(right),
'top': float(top),
'bottom': float(bottom),
'center_x': float((left + right) / 2.0),
'center_y': float((top + bottom) / 2.0),
'baseline': float(y + base_ascent),
'descent': float(base_descent),
'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
draw = ImageDraw.Draw(pil_img)
# Draw text
text_color = tuple(int(c * 255) for c in props.text_color[:4])
@@ -395,8 +656,9 @@ def generate_texture_image(props, width, height):
else:
pass
# Apply any configured image overlays before converting to Blender pixels
pil_img = _apply_composition_components(pil_img)
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)
# Convert PIL image to Blender format (flip vertically to fix mirroring)
pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue