Files
2026-01-02 20:55:29 +00:00

586 lines
26 KiB
Python

#!/usr/bin/env python3
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import torch
import numpy as np
import random
import math
class BookToolsTextToImage:
def __init__(self, device="gpu"):
self.device = device
_positions = ["top", "bottom"]
_alignments = ["left", "center"]
_text_justifications = ["left", "center", "right"]
_colors = [
(255, 50, 50),
(0, 150, 255),
(255, 225, 25),
(255, 150, 50),
(100, 220, 100),
(180, 80, 220),
(0, 210, 210),
(255, 170, 200),
(150, 220, 255),
(170, 255, 170),
(255, 220, 150),
(220, 180, 255),
(255, 100, 100),
(100, 200, 100),
(255, 180, 50),
(80, 150, 255),
(220, 100, 220),
(255, 255, 255),
(240, 240, 0),
(0, 220, 130),
(255, 100, 180),
(255, 200, 0),
(200, 100, 255),
(100, 220, 220),
(255, 150, 150)
]
def font_with_weight(self, font_path):
try:
if "VariableFont" in font_path:
from fontTools.ttLib import TTFont
font = TTFont(font_path)
if 'fvar' in font:
font_obj = ImageFont.truetype(font_path, size=12)
if hasattr(font_obj, 'set_variation_by_axes'):
font_obj.set_variation_by_axes([700]) # Set weight to bold (700)
return font_path
return font_path
except Exception as e:
print(f"Variable font weight setting failed: {e}")
return font_path
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", ),
"text": ("STRING", {"multiline": True, "default": "Hello"}),
"max_font_size": ("INT", {"default": 96, "min": 8, "max": 256}),
"min_font_size": ("INT", {"default": 32, "min": 8, "max": 256}),
"font": ("STRING", {"default": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"}),
"padding": ("INT", {"default": 40, "min": 0}),
"line_height_factor": ("FLOAT", {"default": 1.4, "min": 0.5, "max": 3.0, "step": 0.1}),
"curve_amount": ("FLOAT", {"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.1}),
"char_spacing": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 2.0, "step": 0.1}),
"random_position": ("BOOLEAN", {"default": False}),
"position_offset": ("INT", {"default": 20, "min": 0}),
"shadow_offset": ("INT", {"default": 4, "min": 0, "max": 20}),
"outline_thickness": ("INT", {"default": 3, "min": 1, "max": 10}),
"use_gradient": ("BOOLEAN", {"default": False}),
"gradient_direction": (["vertical", "horizontal"], {"default": "vertical"}),
"background_style": (["none", "rectangle", "ribbon", "glow"], {"default": "none"}),
"background_color": (["auto", "dark", "light"], {"default": "auto"}),
"background_padding": ("INT", {"default": 20, "min": 0, "max": 100}),
"text_justification": (["left", "center", "right"], {"default": "left"}),
"vertical_position": (["top", "center", "bottom"], {"default": "center"}),
"text_color": ("STRING", {"multiline": False, "default": "random"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "create_text_image"
CATEGORY = "image/text"
def calculate_text_width(self, text, font):
"""Calculate exact text width including kerning if available"""
try:
# Try to use layout engine if available (more accurate)
dummy_image = Image.new('RGB', (1, 1))
draw = ImageDraw.Draw(dummy_image)
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
except:
# Fallback to simple width calculation
return sum(font.getbbox(char)[2] - font.getbbox(char)[0] for char in text)
def calculate_text_size(self, text, font_size, font_path, max_width, max_height, line_height_factor, char_spacing_factor, text_justification="left"):
try:
font = ImageFont.truetype(font_path, font_size)
except Exception as e:
print(f"Font error: {str(e)}, using default")
font = ImageFont.load_default()
paragraphs = text.split('\n')
lines = []
max_line_width = 0
# compute pixel spacing from factor
char_spacing = font_size * char_spacing_factor
for paragraph in paragraphs:
words = paragraph.split()
current_line = []
for word in words:
# Test if adding this word would exceed the line width
test_line = current_line + [word]
test_text = ' '.join(test_line)
# Calculate test line width with character spacing (same method as draw_text_with_border)
test_width = 0
for i, char in enumerate(test_text):
bbox = font.getbbox(char)
test_width += (bbox[2] - bbox[0])
if i < len(test_text) - 1: # Add spacing between chars, but not after last
test_width += char_spacing
if test_width <= max_width:
# Word fits, add it to current line
current_line.append(word)
else:
# Word doesn't fit, finish current line and start new one
if current_line:
line_text = ' '.join(current_line)
lines.append(line_text)
# Calculate line width consistently
line_width = 0
for i, char in enumerate(line_text):
bbox = font.getbbox(char)
line_width += (bbox[2] - bbox[0])
if i < len(line_text) - 1: # Only add spacing between chars, not after last
line_width += char_spacing
max_line_width = max(max_line_width, line_width)
# Start new line with current word
current_line = [word]
# Add the last line if it exists
if current_line:
line_text = ' '.join(current_line)
lines.append(line_text)
# Calculate line width consistently
line_width = 0
for i, char in enumerate(line_text):
bbox = font.getbbox(char)
line_width += (bbox[2] - bbox[0])
if i < len(line_text) - 1: # Only add spacing between chars, not after last
line_width += char_spacing
max_line_width = max(max_line_width, line_width)
line_height = int(font_size * line_height_factor)
total_height = len(lines) * line_height
# Check if the text fits both width and height constraints
fits = (max_line_width <= max_width) and (total_height <= max_height)
return lines, fits, max_line_width, total_height
def calculate_line_x_position(self, line, font, justification, container_width, padding, char_spacing_factor):
"""Calculate x position for a line based on justification"""
# Calculate total line width including character spacing (same as draw_text_with_border)
total_width = 0
for i, char in enumerate(line):
bbox = font.getbbox(char)
char_width = bbox[2] - bbox[0]
total_width += char_width
if i < len(line) - 1: # Add spacing between chars, but not after last char
total_width += font.size * char_spacing_factor
# Calculate effective width (considering only main padding)
effective_width = container_width - (2 * padding)
if justification == "left":
return padding
elif justification == "center":
# Center within the effective area, but ensure it doesn't go outside boundaries
center_x = padding + (effective_width - total_width) // 2
# Ensure text doesn't go outside left or right boundaries
min_x = padding
max_x = container_width - total_width - padding
return max(min_x, min(max_x, center_x))
elif justification == "right":
return container_width - total_width - padding
else:
return padding # Default to left
def create_gradient_color(self, color, height, direction="vertical"):
start_color = color
end_color = tuple(max(0, c - 80) for c in color) # Darker version
gradient_colors = []
for i in range(height):
if direction == "vertical":
ratio = i / height
else:
ratio = 0.5 # For horizontal, we'll handle it differently
gradient_color = tuple(
int(start_color[j] + (end_color[j] - start_color[j]) * ratio)
for j in range(3)
)
gradient_colors.append(gradient_color)
return gradient_colors
def draw_background(self, draw, x, y, width, height, style, color_mode, base_color):
if style == "none":
return
bg_color = self.get_background_color(color_mode, base_color)
# Create a separate image for the background with blur
bg_img = Image.new('RGBA', draw._image.size, (0,0,0,0))
bg_draw = ImageDraw.Draw(bg_img)
if style == "rectangle":
# Draw rounded rectangle with shadow
shadow_offset = 8
shadow_color = (0, 0, 0, 100)
self.rounded_rectangle(bg_draw,
(x + shadow_offset, y + shadow_offset,
x + width + shadow_offset, y + height + shadow_offset),
fill=shadow_color, radius=20)
self.rounded_rectangle(bg_draw, (x, y, x + width, y + height),
fill=bg_color, radius=20)
elif style == "ribbon":
# Draw ribbon effect
points = [
(x, y + height/2),
(x + 20, y),
(x + width - 20, y),
(x + width, y + height/2),
(x + width - 20, y + height),
(x + 20, y + height)
]
bg_draw.polygon(points, fill=bg_color)
elif style == "glow":
# Create radial gradient glow with reduced opacity
for i in range(20, 0, -1):
# Reduce overall opacity to 60% of original
alpha = int(255 * 0.6 * (1 - i/20))
glow_color = (*bg_color[:3], alpha)
expanded_rect = (
x - i, y - i,
x + width + i, y + height + i
)
self.rounded_rectangle(bg_draw, expanded_rect, fill=glow_color, radius=20+i)
# Apply gaussian blur to background
bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=3))
# Paste blurred background onto main image
draw._image.paste(bg_img, (0,0), bg_img)
def get_background_color(self, mode, text_color):
if mode == "auto":
# Create contrasting color
brightness = sum(text_color[:3])/3
return (30, 30, 30, 180) if brightness > 128 else (225, 225, 225, 180)
elif mode == "dark":
return (30, 30, 30, 180)
else: # light
return (225, 225, 225, 220)
def draw_text_with_border(self, draw, text, font, x, y, char_spacing, fill_color=None,
shadow_offset=4, outline_thickness=3, use_gradient=False,
gradient_direction="vertical", background_style="none",
background_color="auto", background_padding=20):
if fill_color is None:
fill_color = random.choice(self._colors)
# Calculate text dimensions including spacing
total_width = 0
for i, char in enumerate(text):
bbox = font.getbbox(char)
char_width = bbox[2] - bbox[0]
total_width += char_width
if i < len(text) - 1: # Add spacing between chars
total_width += font.size * char_spacing
# Get text height
text_height = font.getbbox(text)[3] - font.getbbox(text)[1]
# Draw background if needed
if background_style != "none":
bg_x = x - background_padding
bg_y = y - background_padding
bg_width = total_width + (background_padding * 2) # Now includes char spacing
bg_height = text_height + (background_padding * 2)
self.draw_background(draw, bg_x, bg_y, bg_width, bg_height,
background_style, background_color, fill_color)
# Calculate spacing and dimensions
spacing = font.size * char_spacing
text_height = font.getbbox(text)[3] - font.getbbox(text)[1]
# Calculate text dimensions first
bbox = draw.textbbox((x, y), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Create gradient colors if needed
gradient_colors = self.create_gradient_color(fill_color, text_height, gradient_direction) if use_gradient else None
# Draw shadow first (offset version)
shadow_color = (30, 30, 30)
current_x = x
for i, char in enumerate(text):
bbox = font.getbbox(char)
width = bbox[2] - bbox[0]
# Draw shadow with increasing offset
shadow_y_offset = shadow_offset + (i % 2) # Slightly varied shadow
draw.text((current_x + shadow_offset, y + shadow_y_offset), char, font=font, fill=shadow_color)
current_x += width + (spacing if i < len(text) - 1 else 0)
# Draw outline and main text
current_x = x
for i, char in enumerate(text):
bbox = font.getbbox(char)
width = bbox[2] - bbox[0]
# Enhanced outline with varied thickness
for dx, dy in [(i,j) for i in range(-outline_thickness, outline_thickness+1)
for j in range(-outline_thickness, outline_thickness+1)
if (i*i + j*j) <= outline_thickness*outline_thickness]:
draw.text((current_x+dx, y+dy), char, font=font, fill=(0,0,0))
if use_gradient:
# Create temporary image for this character
char_img = Image.new('RGBA', (width, text_height), (0,0,0,0))
char_draw = ImageDraw.Draw(char_img)
# Draw character in white to create mask
char_draw.text((0, 0), char, font=font, fill=(255,255,255,255))
# Create gradient image
gradient_img = Image.new('RGBA', (width, text_height), (0,0,0,0))
for h in range(text_height):
color = gradient_colors[h]
ImageDraw.Draw(gradient_img).line([(0, h), (width, h)], fill=(*color, 255))
# Use character as mask for gradient
char_mask = char_img.split()[3] # Get alpha channel
gradient_img.putalpha(char_mask)
# Paste the gradient character
draw._image.paste(gradient_img, (int(current_x), int(y)), char_mask)
else:
# Regular colored text
draw.text((current_x, y), char, font=font, fill=fill_color)
current_x += width + (spacing if i < len(text) - 1 else 0)
def draw_curved_text(self, draw, text, font, x, y, width, curve_amount, char_spacing, text_color=None):
# Calculate the maximum width available for text
usable_width = min(width * 0.9, draw._image.width - 100)
# Calculate spacing
spacing = font.size * char_spacing
# Calculate character widths and total width without extra spacing at end
char_widths = []
total_natural_width = 0
for i, char in enumerate(text):
bbox = font.getbbox(char)
char_width = bbox[2] - bbox[0]
char_widths.append(char_width)
total_natural_width += char_width
# Only add spacing between characters, not after the last one
if i < len(text) - 1:
total_natural_width += spacing
# Set starting position correctly based on total width
start_x = x - total_natural_width/2
current_x = start_x
if text_color is None:
text_color = random.choice(self._colors)
for i, (char, char_width) in enumerate(zip(text, char_widths)):
# Calculate character center and progress
char_center_x = current_x + char_width/2
progress = (char_center_x - x) / (usable_width/2)
progress = max(-1, min(1, progress)) # Clamp progress
offset_y = curve_amount * width * (progress * progress)
# Create character image with padding
padding = int(font.size * 0.2)
char_img = Image.new('RGBA', (int(char_width + padding*2), int(font.size*2)), (0,0,0,0))
char_draw = ImageDraw.Draw(char_img)
# Center character in its image
char_x = padding
char_y = char_img.height/2 - font.size/2
# Draw border
thickness = max(2, font.size // 25)
for dx, dy in [(-thickness,-thickness), (thickness,-thickness), (-thickness,thickness), (thickness,thickness),
(-thickness,0), (thickness,0), (0,-thickness), (0,thickness)]:
char_draw.text((char_x+dx, char_y+dy), char, font=font, fill=(0,0,0,255))
# Draw character
char_draw.text((char_x, char_y), char, font=font, fill=(*text_color, 255))
# Calculate and clamp rotation angle
angle = math.degrees(math.atan2(2 * curve_amount * width * progress, width/2))
angle = max(-30, min(30, angle)) # Limit rotation more
rotated = char_img.rotate(angle, expand=True, resample=Image.BICUBIC)
# Calculate paste position
paste_x = int(current_x - rotated.width/2)
paste_y = int(y + offset_y - rotated.height/2)
# Ensure within bounds
paste_x = max(0, min(draw._image.width - rotated.width, paste_x))
paste_y = max(0, min(draw._image.height - rotated.height, paste_y))
mask = rotated.split()[3]
draw._image.paste(rotated, (paste_x, paste_y), mask)
# Move to next character position with consistent spacing
current_x += char_width
if i < len(text) - 1: # Only add spacing if not the last character
current_x += spacing
def create_text_image(self, image, text, max_font_size, min_font_size, font, padding,
line_height_factor, curve_amount=0.0, char_spacing=0.2,
random_position=False, position_offset=20, shadow_offset=4,
outline_thickness=3, use_gradient=False, gradient_direction="vertical",
background_style="none", background_color="auto", background_padding=20,
text_justification="left", vertical_position="center", text_color="random"):
image_tensor = image
image_np = image_tensor.cpu().numpy()
image = Image.fromarray((image_np.squeeze(0) * 255).astype(np.uint8))
width, height = image.size
draw = ImageDraw.Draw(image)
font = self.font_with_weight(font)
try:
# Calculate effective width and height considering padding
effective_width = width - (2 * padding)
effective_height = height - (2 * padding)
# Binary search to find optimal font size
low, high = min_font_size, max_font_size
optimal_font_size = min_font_size # Initialize with min_font_size
optimal_lines = []
# First check if min_font_size fits
lines, fits, actual_width, actual_height = self.calculate_text_size(
text, min_font_size, font, effective_width, effective_height, line_height_factor, char_spacing, text_justification
)
if fits:
# Use binary search to find the largest font size that fits
while low <= high:
mid = (low + high) // 2
lines, fits, actual_width, actual_height = self.calculate_text_size(
text, mid, font, effective_width, effective_height, line_height_factor, char_spacing, text_justification
)
if fits:
optimal_font_size = mid
optimal_lines = lines
low = mid + 1
else:
high = mid - 1
else:
# Even min_font_size doesn't fit, use it anyway with overflow
optimal_font_size = min_font_size
optimal_lines = lines
loaded_font = ImageFont.truetype(font, optimal_font_size)
except Exception as e:
print(f"Warning: Could not load font {font}: {str(e)}, using default")
loaded_font = ImageFont.load_default()
optimal_lines = [text.upper()]
line_height = int(optimal_font_size * line_height_factor)
total_height = len(optimal_lines) * line_height
if random_position:
vertical_pos = random.choice(self._positions)
horizontal_pos = random.choice(self._alignments)
if vertical_pos == "top":
base_y = padding + position_offset
elif vertical_pos == "bottom":
base_y = height - total_height - padding - position_offset
else:
base_y = (height - total_height) // 2
base_y += random.randint(-10, 10)
else:
# Use the vertical_position parameter (consistent with effective area calculation)
if vertical_position == "top":
base_y = padding
elif vertical_position == "bottom":
base_y = height - total_height - padding
else: # center
base_y = padding + (effective_height - total_height) // 2
y = base_y
if text_color == "random":
text_color = random.choice(self._colors)
else:
text_color = tuple(int(text_color.lstrip("#")[i:i+2], 16) for i in (0, 2, 4))
for line in optimal_lines:
if curve_amount != 0:
# For curved text, center is already handled in draw_curved_text
self.draw_curved_text(draw, line, loaded_font, width//2, y+2, width - 2*padding, curve_amount, char_spacing,
text_color=(max(0, text_color[0] - 100),
max(0, text_color[1] - 100),
max(0, text_color[2] - 100)))
self.draw_curved_text(draw, line, loaded_font, width//2, y, width - 2*padding, curve_amount, char_spacing,
text_color=text_color)
else:
# Calculate x position based on justification
if random_position:
# For random positioning, use the existing logic but adjust for justification
if horizontal_pos == "left":
base_x = padding + position_offset
else: # center
base_x = self.calculate_line_x_position(line, loaded_font, "center", width, padding, char_spacing)
x = base_x + random.randint(-10, 10)
else:
# Use text justification parameter
x = self.calculate_line_x_position(line, loaded_font, text_justification, width, padding, char_spacing)
self.draw_text_with_border(draw, line, loaded_font, x, y, char_spacing, text_color, shadow_offset, outline_thickness, use_gradient, gradient_direction, background_style, background_color, background_padding)
y += line_height
image_tensor_out = torch.tensor(np.array(image).astype(np.float32) / 255.0)
image_tensor_out = torch.unsqueeze(image_tensor_out, 0)
return (image_tensor_out,)
@staticmethod
def rounded_rectangle(draw, bbox, fill=None, radius=20):
x1, y1, x2, y2 = bbox
# Ensure radius isn't too large for the rectangle
width = x2 - x1
height = y2 - y1
radius = min(radius, width//2, height//2)
if radius <= 0:
draw.rectangle(bbox, fill=fill)
return
draw.rectangle((x1+radius, y1, x2-radius, y2), fill=fill)
draw.rectangle((x1, y1+radius, x2, y2-radius), fill=fill)
# Draw corners
draw.pieslice((x1, y1, x1+radius*2, y1+radius*2), 180, 270, fill=fill)
draw.pieslice((x2-radius*2, y1, x2, y1+radius*2), 270, 0, fill=fill)
draw.pieslice((x1, y2-radius*2, x1+radius*2, y2), 90, 180, fill=fill)
draw.pieslice((x2-radius*2, y2-radius*2, x2, y2), 0, 90, fill=fill)