520 lines
21 KiB
Python
520 lines
21 KiB
Python
import re
|
|
from PIL import Image, ImageDraw, ImageFont # Make sure ImageDraw is imported
|
|
import os
|
|
import zipfile
|
|
|
|
TEXTURE_SIZE = 16 # Default Minecraft texture size in pixels
|
|
|
|
# --- Constants for Blueprint Generation ---
|
|
OUTPUT_BLUEPRINT_DIR = "blueprints" # Directory to save generated blueprints
|
|
|
|
# --- Define the name for your missing texture file ---
|
|
MISSING_TEXTURE_FILENAME = "missing_texture.png" # Make sure this matches your file name
|
|
|
|
|
|
# --- Function to extract base block name from Java block string ---
|
|
def get_base_java_block_name(java_block_string, overrides):
|
|
"""
|
|
Extracts the base block name (e.g., 'stone', 'oak_log') from a full
|
|
Java block string (e.g., 'minecraft:stone', 'minecraft:oak_log[axis=y]'),
|
|
applying texture name overrides.
|
|
"""
|
|
# 1. Get the raw base name from the Java block string
|
|
name_without_prefix = java_block_string.replace("minecraft:", "")
|
|
raw_base_name = name_without_prefix.split('[')[0] # e.g., "spruce_trapdoor"
|
|
|
|
# 2. Apply override if defined, otherwise return the raw_base_name
|
|
overridden_name = overrides.get(raw_base_name, raw_base_name)
|
|
|
|
return overridden_name
|
|
|
|
def parse_block_states(java_block_string):
|
|
"""
|
|
Parses a Java block string to extract its state properties.
|
|
e.g., "minecraft:spruce_trapdoor[facing=north,half=bottom]" -> {'facing': 'north', 'half': 'bottom'}
|
|
"""
|
|
states = {}
|
|
match = re.search(r'\[(.*?)\]', java_block_string)
|
|
if match:
|
|
properties_str = match.group(1)
|
|
# Split by comma, then by equals sign
|
|
for prop_pair in properties_str.split(','):
|
|
if '=' in prop_pair:
|
|
key, value = prop_pair.split('=', 1)
|
|
states[key] = value
|
|
return states
|
|
|
|
def extract_java_textures(java_jar_path, output_dir):
|
|
"""
|
|
Extracts block textures from a Minecraft Java Edition client JAR.
|
|
|
|
Args:
|
|
java_jar_path (str): Full path to the Minecraft client JAR file.
|
|
output_dir (str): Directory where the extracted textures will be saved (e.g., 'java_textures' will be created inside this).
|
|
"""
|
|
if not os.path.exists(java_jar_path):
|
|
print(f"Error: Java JAR file not found at {java_jar_path}")
|
|
return
|
|
|
|
texture_source_path_in_jar = 'assets/minecraft/textures/block/'
|
|
extracted_texture_path = os.path.join(output_dir, 'java_textures') # Save to a subfolder
|
|
|
|
os.makedirs(extracted_texture_path, exist_ok=True)
|
|
|
|
print(f"Extracting textures from {java_jar_path} to {extracted_texture_path}...")
|
|
|
|
with zipfile.ZipFile(java_jar_path, 'r') as zip_ref:
|
|
for member in zip_ref.namelist():
|
|
if member.startswith(texture_source_path_in_jar) and member.endswith('.png'):
|
|
# Get the relative path within the block textures folder
|
|
relative_path = os.path.relpath(member, texture_source_path_in_jar)
|
|
|
|
# Construct the full output path
|
|
destination_path = os.path.join(extracted_texture_path, relative_path)
|
|
|
|
# Ensure the directory exists
|
|
os.makedirs(os.path.dirname(destination_path), exist_ok=True)
|
|
|
|
# Extract the file
|
|
with open(destination_path, 'wb') as outfile:
|
|
outfile.write(zip_ref.read(member))
|
|
|
|
print("Texture extraction complete.")
|
|
|
|
def create_arrow_texture(direction, size=TEXTURE_SIZE):
|
|
"""
|
|
Creates a red arrow pointing in the specified direction.
|
|
Args:
|
|
direction (str): 'north', 'south', 'east', 'west'.
|
|
size (int): The size of the square texture (e.g., 16).
|
|
Returns:
|
|
PIL.Image: An RGBA image of the arrow.
|
|
"""
|
|
arrow_img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) # Transparent background
|
|
draw = ImageDraw.Draw(arrow_img)
|
|
|
|
line_color = (255, 0, 0, 255) # Red with full alpha
|
|
line_width = 1 # Pixels
|
|
|
|
center_x, center_y = size // 2, size // 2
|
|
|
|
# Define arrow head size relative to TEXTURE_SIZE
|
|
# head_size = size // 4 # Example: 4 pixels for 16x16 texture
|
|
head_size = size // 8 # Example: 2 pixels for 16x16 texture
|
|
|
|
if direction == 'north': # Arrow points up (towards decreasing Z)
|
|
draw.line([(center_x, size), (center_x, 0)], fill=line_color, width=line_width)
|
|
draw.polygon([(center_x, 0), (center_x - head_size, head_size), (center_x + head_size, head_size)], fill=line_color)
|
|
elif direction == 'south': # Arrow points down (towards increasing Z)
|
|
draw.line([(center_x, 0), (center_x, size)], fill=line_color, width=line_width)
|
|
draw.polygon([(center_x, size), (center_x - head_size, size - head_size), (center_x + head_size, size - head_size)], fill=line_color)
|
|
elif direction == 'west': # Arrow points left (towards decreasing X)
|
|
draw.line([(size, center_y), (0, center_y)], fill=line_color, width=line_width)
|
|
draw.polygon([(0, center_y), (head_size, center_y - head_size), (head_size, center_y + head_size)], fill=line_color)
|
|
elif direction == 'east': # Arrow points right (towards increasing X)
|
|
draw.line([(0, center_y), (size, center_y)], fill=line_color, width=line_width)
|
|
draw.polygon([(size, center_y), (size - head_size, center_y - head_size), (size - head_size, center_y + head_size)], fill=line_color)
|
|
else:
|
|
print(f"Warning: Unknown direction '{direction}' for arrow.")
|
|
return Image.new('RGBA', (size, size), (0, 0, 0, 0)) # Return transparent if direction is invalid
|
|
|
|
return arrow_img
|
|
|
|
def draw_door_state_overlay(texture: Image.Image, is_open: bool):
|
|
"""
|
|
Draws a small overlay symbol in the top-left corner of the texture to indicate door state.
|
|
- Green open rectangle for open
|
|
- Red closed rectangle for closed
|
|
"""
|
|
draw = ImageDraw.Draw(texture)
|
|
size = texture.width
|
|
overlay_size = size // 4 # 1/4 of texture size
|
|
|
|
x0, y0 = 2, 2
|
|
x1, y1 = x0 + overlay_size, y0 + overlay_size
|
|
|
|
if is_open:
|
|
# Draw a green open rectangle (open door shape)
|
|
draw.rectangle([x0, y0, x1, y1], outline=(0, 200, 0, 255), width=2)
|
|
# Draw a gap to indicate "open"
|
|
draw.line([x0 + overlay_size//2, y0, x1, y1], fill=(0, 200, 0, 255), width=2)
|
|
else:
|
|
# Draw a red closed rectangle (closed door shape)
|
|
draw.rectangle([x0, y0, x1, y1], outline=(200, 0, 0, 255), width=2)
|
|
# Draw a line to indicate "closed"
|
|
draw.line([x0, y0, x1, y1], fill=(200, 0, 0, 255), width=2)
|
|
|
|
def combine_door_halves(
|
|
bottom_texture: Image.Image,
|
|
top_texture: Image.Image,
|
|
half: str = "lower",
|
|
overlay_opacity: int = 128 # 0-255, 128 = 50% opacity
|
|
) -> Image.Image:
|
|
"""
|
|
Combines the bottom and top door textures into a single 16x16 image.
|
|
The 'other' half is shown with a semi-transparent white overlay.
|
|
Args:
|
|
bottom_texture: PIL.Image for the door bottom (8x16 or 16x16, will be cropped).
|
|
top_texture: PIL.Image for the door top (8x16 or 16x16, will be cropped).
|
|
half: "lower" or "upper" (which half is the real one)
|
|
overlay_opacity: Opacity for the overlay (0=transparent, 255=opaque)
|
|
Returns:
|
|
PIL.Image: The combined 16x16 image.
|
|
"""
|
|
# Ensure both textures are 16x16 (crop or resize if needed)
|
|
size = 16
|
|
bottom = bottom_texture.crop((0, 0, size, size))
|
|
top = top_texture.crop((0, 0, size, size))
|
|
|
|
# Create a new blank image
|
|
combined = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
|
|
if half == "lower":
|
|
# Place bottom half as normal
|
|
combined.paste(bottom, (0, 8))
|
|
# Place top half above, faded
|
|
faded_top = top.crop((0, 0, size, 8)).copy()
|
|
overlay = Image.new("RGBA", (size, 8), (255, 255, 255, overlay_opacity))
|
|
faded_top = Image.alpha_composite(faded_top, overlay)
|
|
combined.paste(faded_top, (0, 0), faded_top)
|
|
else: # half == "upper"
|
|
# Place top half as normal
|
|
combined.paste(top, (0, 0))
|
|
# Place bottom half below, faded
|
|
faded_bottom = bottom.crop((0, 8, size, 16)).copy()
|
|
overlay = Image.new("RGBA", (size, 8), (255, 255, 255, overlay_opacity))
|
|
faded_bottom = Image.alpha_composite(faded_bottom, overlay)
|
|
combined.paste(faded_bottom, (0, 8), faded_bottom)
|
|
|
|
return combined
|
|
|
|
def draw_door_overlay_symbols(
|
|
texture: Image.Image,
|
|
show_lock: bool = False,
|
|
hinge_side: str = "left"
|
|
):
|
|
"""
|
|
Draws overlay symbols in the top-left corner of the texture for doors:
|
|
- Optionally draws an unlocked (open) padlock (if show_lock is True)
|
|
- Optionally draws a small gray vertical bar on the hinge side ('left' or 'right')
|
|
Args:
|
|
texture (PIL.Image): The texture to draw on.
|
|
show_lock (bool): If True, draws an unlocked padlock.
|
|
hinge_side (str): 'left' or 'right' to indicate hinge side, or None.
|
|
"""
|
|
|
|
# Flip texture if hinge is right
|
|
if hinge_side =="right":
|
|
texture = texture.transpose(Image.FLIP_LEFT_RIGHT)
|
|
|
|
draw = ImageDraw.Draw(texture)
|
|
size = texture.width
|
|
overlay_size = size // 4 # 1/4 of texture size
|
|
|
|
x0, y0 = 2, 2
|
|
x1, y1 = x0 + overlay_size, y0 + overlay_size
|
|
|
|
# Draw hinge bar if specified
|
|
if hinge_side in ("left", "right"):
|
|
bar_width = max(1, overlay_size // 6)
|
|
bar_color = (120, 120, 120, 255) # Gray
|
|
if hinge_side == "left":
|
|
bar_x0 = x0
|
|
bar_x1 = x0 + bar_width
|
|
else: # "right"
|
|
bar_x0 = x1 - bar_width
|
|
bar_x1 = x1
|
|
draw.rectangle([bar_x0, y0, bar_x1, y1], fill=bar_color)
|
|
|
|
if show_lock:
|
|
# Draw an unlocked (open) padlock
|
|
lock_body_top = y1 - overlay_size // 3
|
|
lock_body_bottom = y1 - 2
|
|
lock_body_left = x0 + overlay_size // 4
|
|
lock_body_right = x1 - overlay_size // 4
|
|
# Body of the lock
|
|
draw.rectangle(
|
|
[lock_body_left, lock_body_top, lock_body_right, lock_body_bottom],
|
|
fill=(200, 200, 0, 255), outline=(120, 120, 0, 255), width=1
|
|
)
|
|
# Shackle of the lock (open arc)
|
|
shackle_box = [
|
|
lock_body_left - overlay_size // 8, lock_body_top - overlay_size // 4,
|
|
lock_body_right + overlay_size // 8, lock_body_top + overlay_size // 4
|
|
]
|
|
# Draw only part of the arc to look "open"
|
|
draw.arc(shackle_box, start=30, end=150, fill=(120, 120, 0, 255), width=2)
|
|
|
|
def render_grid(
|
|
textures_2d,
|
|
texture_size,
|
|
normal_padding=1,
|
|
bold_padding=3,
|
|
grid_line_color=(150, 150, 150, 255),
|
|
grid_line_bold_color=(80, 80, 80, 255),
|
|
grid_line_width=1,
|
|
grid_line_bold_width=3,
|
|
font_path="arial.ttf",
|
|
font_size=10,
|
|
margin=24
|
|
):
|
|
"""
|
|
Renders a 2D grid of textures with variable padding for gridlines, bold every 5th line,
|
|
and draws numbering outside the grid in an expanded canvas.
|
|
Args:
|
|
textures_2d: 2D list of PIL.Image objects (row-major: [z][x])
|
|
texture_size: Size of each texture square (pixels)
|
|
normal_padding: Space (pixels) between cells for normal gridlines
|
|
bold_padding: Space (pixels) between cells for every 5th (bold) gridline
|
|
grid_line_color: Color for normal grid lines
|
|
grid_line_bold_color: Color for every 5th grid line
|
|
grid_line_width: Width for normal grid lines
|
|
grid_line_bold_width: Width for every 5th grid line
|
|
font_path: Path to the font file
|
|
font_size: Font size for numbering
|
|
margin: Margin in pixels for numbering outside the grid
|
|
Returns:
|
|
PIL.Image: The rendered grid image with variable padding, gridlines, and outside numbering
|
|
"""
|
|
length = len(textures_2d)
|
|
width = len(textures_2d[0]) if length > 0 else 0
|
|
|
|
# Build padding arrays for columns and rows
|
|
paddings_col = []
|
|
for col in range(width + 1):
|
|
if col % 5 == 0:
|
|
paddings_col.append(bold_padding)
|
|
else:
|
|
paddings_col.append(normal_padding)
|
|
paddings_row = []
|
|
for row in range(length + 1):
|
|
if row % 5 == 0:
|
|
paddings_row.append(bold_padding)
|
|
else:
|
|
paddings_row.append(normal_padding)
|
|
|
|
# Calculate total image size (without margin)
|
|
img_width = width * texture_size + sum(paddings_col)
|
|
img_height = length * texture_size + sum(paddings_row)
|
|
|
|
# Create expanded image with margin for numbering (top and left)
|
|
expanded_width = img_width + margin
|
|
expanded_height = img_height + margin
|
|
out_img = Image.new('RGBA', (expanded_width, expanded_height), (255, 255, 255, 0))
|
|
draw = ImageDraw.Draw(out_img)
|
|
|
|
# Try to load a default font; fallback if not available
|
|
try:
|
|
font = ImageFont.truetype(font_path, font_size)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
|
|
# Precompute cumulative paddings for fast lookup
|
|
cum_paddings_col = [0]
|
|
for pad in paddings_col[:-1]:
|
|
cum_paddings_col.append(cum_paddings_col[-1] + pad + texture_size)
|
|
cum_paddings_row = [0]
|
|
for pad in paddings_row[:-1]:
|
|
cum_paddings_row.append(cum_paddings_row[-1] + pad + texture_size)
|
|
|
|
# Paste textures with variable padding, offset by margin
|
|
for z in range(length):
|
|
for x in range(width):
|
|
tx = margin + sum(paddings_col[:x+1]) + x * texture_size
|
|
ty = margin + sum(paddings_row[:z+1]) + z * texture_size
|
|
out_img.paste(textures_2d[z][x], (tx, ty), textures_2d[z][x])
|
|
|
|
# Draw vertical gridlines and outside numbering
|
|
x = margin
|
|
for col in range(width + 1):
|
|
is_bold = (col % 5 == 0)
|
|
color = grid_line_bold_color if is_bold else grid_line_color
|
|
line_width = grid_line_bold_width if is_bold else grid_line_width
|
|
pad = paddings_col[col]
|
|
offset = (pad - line_width) // 2
|
|
draw.line([(x + offset, margin), (x + offset, margin + img_height)], fill=color, width=line_width)
|
|
|
|
# Draw numbering outside the grid (above the grid)
|
|
if is_bold and col != 0 and x < margin + img_width:
|
|
text = str(col)
|
|
bbox = font.getbbox(text)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
text_x = x + offset - text_width // 2 + line_width // 2
|
|
text_y = (margin - text_height - 2) if (margin - text_height - 2) > 0 else 2
|
|
draw.rectangle(
|
|
[text_x, text_y, text_x + text_width, text_y + text_height],
|
|
fill=(255, 255, 255, 180)
|
|
)
|
|
draw.text((text_x, text_y), text, fill=(0, 0, 0, 255), font=font)
|
|
x += pad + (texture_size if col < width else 0)
|
|
|
|
# Draw horizontal gridlines and outside numbering
|
|
y = margin
|
|
for row in range(length + 1):
|
|
is_bold = (row % 5 == 0)
|
|
color = grid_line_bold_color if is_bold else grid_line_color
|
|
line_width = grid_line_bold_width if is_bold else grid_line_width
|
|
pad = paddings_row[row]
|
|
offset = (pad - line_width) // 2
|
|
draw.line([(margin, y + offset), (margin + img_width, y + offset)], fill=color, width=line_width)
|
|
|
|
# Draw numbering outside the grid (to the left of the grid)
|
|
if is_bold and row != 0 and y < margin + img_height:
|
|
text = str(row)
|
|
bbox = font.getbbox(text)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
text_x = (margin - text_width - 2) if (margin - text_width - 2) > 0 else 2
|
|
text_y = y + offset - text_height // 2 + line_width // 2
|
|
draw.rectangle(
|
|
[text_x, text_y, text_x + text_width, text_y + text_height],
|
|
fill=(255, 255, 255, 180)
|
|
)
|
|
draw.text((text_x, text_y), text, fill=(0, 0, 0, 255), font=font)
|
|
y += pad + (texture_size if row < length else 0)
|
|
|
|
return out_img
|
|
|
|
def draw_blueprint_legend(
|
|
image: Image.Image,
|
|
font_path="arial.ttf",
|
|
font_size=14,
|
|
margin=16,
|
|
box_width=220,
|
|
box_height=110
|
|
):
|
|
"""
|
|
Draws a legend box in the top-left corner of the blueprint image.
|
|
Explains gridlines, padlock, hinge bar, and other symbols.
|
|
"""
|
|
draw = ImageDraw.Draw(image)
|
|
try:
|
|
font = ImageFont.truetype(font_path, font_size)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
|
|
# Legend box background
|
|
box_x0, box_y0 = margin, margin
|
|
box_x1, box_y1 = box_x0 + box_width, box_y0 + box_height
|
|
draw.rectangle([box_x0, box_y0, box_x1, box_y1], fill=(255, 255, 255, 230), outline=(80, 80, 80, 255), width=2)
|
|
|
|
y = box_y0 + 10
|
|
x = box_x0 + 10
|
|
line_spacing = font_size + 6
|
|
|
|
# 1. Gridlines
|
|
# Normal
|
|
draw.line([x, y + font_size // 2, x + 30, y + font_size // 2], fill=(150, 150, 150, 255), width=1)
|
|
draw.text((x + 40, y), "Normal gridline", fill=(0, 0, 0, 255), font=font)
|
|
y += line_spacing
|
|
# Bold
|
|
draw.line([x, y + font_size // 2, x + 30, y + font_size // 2], fill=(80, 80, 80, 255), width=3)
|
|
draw.text((x + 40, y), "Every 5th gridline", fill=(0, 0, 0, 255), font=font)
|
|
y += line_spacing
|
|
|
|
# 2. Padlock (unlocked)
|
|
padlock_x = x + 5
|
|
padlock_y = y + 2
|
|
# Draw padlock body
|
|
draw.rectangle([padlock_x, padlock_y + 8, padlock_x + 12, padlock_y + 16], fill=(200, 200, 0, 255), outline=(120, 120, 0, 255), width=1)
|
|
# Draw shackle (open arc)
|
|
draw.arc([padlock_x - 2, padlock_y + 2, padlock_x + 14, padlock_y + 14], start=30, end=150, fill=(120, 120, 0, 255), width=2)
|
|
draw.text((x + 40, y), "Unlocked door", fill=(0, 0, 0, 255), font=font)
|
|
y += line_spacing
|
|
|
|
# 3. Hinge bar
|
|
bar_x = x + 5
|
|
bar_y = y + 4
|
|
draw.rectangle([bar_x, bar_y, bar_x + 4, bar_y + 16], fill=(120, 120, 120, 255))
|
|
draw.text((x + 40, y), "Door hinge side", fill=(0, 0, 0, 255), font=font)
|
|
y += line_spacing
|
|
|
|
# 4. Arrow
|
|
arrow_x = x + 5
|
|
arrow_y = y + 10
|
|
draw.line([arrow_x, arrow_y, arrow_x + 16, arrow_y], fill=(200, 0, 0, 255), width=3)
|
|
draw.polygon([arrow_x + 16, arrow_y - 4, arrow_x + 24, arrow_y, arrow_x + 16, arrow_y + 4], fill=(200, 0, 0, 255))
|
|
draw.text((x + 40, y), "Facing direction", fill=(0, 0, 0, 255), font=font)
|
|
# (Add more symbols as needed)
|
|
|
|
def draw_dynamic_block_legend_left(
|
|
grid_image: Image.Image,
|
|
textures_2d,
|
|
java_textures_loaded,
|
|
block_names_2d,
|
|
font_path="arial.ttf",
|
|
font_size=14,
|
|
margin=24,
|
|
entry_height=28,
|
|
entry_icon_size=20,
|
|
legend_bg=(255, 255, 255, 230),
|
|
legend_outline=(80, 80, 80, 255)
|
|
):
|
|
"""
|
|
Draws a legend for only the block types used in the current layer, in a margin to the left of the grid.
|
|
Returns a new image with the legend and the grid side by side.
|
|
Args:
|
|
grid_image: The rendered grid image (PIL.Image).
|
|
textures_2d: 2D list of PIL.Image objects for the layer.
|
|
java_textures_loaded: dict mapping block name to PIL.Image.
|
|
block_names_2d: 2D list of block base names (same shape as textures_2d).
|
|
font_path: Path to font file.
|
|
font_size: Font size for legend text.
|
|
margin: Margin between legend and grid.
|
|
entry_height: Height of each legend entry.
|
|
entry_icon_size: Size of the block icon in the legend.
|
|
legend_bg: RGBA tuple for legend background.
|
|
legend_outline: RGBA tuple for legend outline.
|
|
Returns:
|
|
PIL.Image: New image with legend on the left and grid on the right.
|
|
"""
|
|
# 1. Find unique block types in the layer (excluding 'air')
|
|
unique_blocks = []
|
|
seen = set()
|
|
for row in block_names_2d:
|
|
for name in row:
|
|
if name != 'air' and name not in seen:
|
|
unique_blocks.append(name)
|
|
seen.add(name)
|
|
|
|
if not unique_blocks:
|
|
# No blocks, just return the grid image
|
|
return grid_image
|
|
|
|
# 2. Prepare legend image
|
|
legend_width = margin + entry_icon_size + 10 + 120 + margin # icon + spacing + text + margin
|
|
legend_height = margin + len(unique_blocks) * entry_height + margin
|
|
legend_img = Image.new('RGBA', (legend_width, legend_height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(legend_img)
|
|
try:
|
|
font = ImageFont.truetype(font_path, font_size)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
|
|
# Draw legend background and outline
|
|
draw.rectangle([0, 0, legend_width-1, legend_height-1], fill=legend_bg, outline=legend_outline, width=2)
|
|
|
|
# 3. Draw each block type in the legend
|
|
y = margin
|
|
for block_name in unique_blocks:
|
|
# Draw block icon
|
|
icon = java_textures_loaded.get(block_name)
|
|
if icon is not None:
|
|
icon = icon.resize((entry_icon_size, entry_icon_size), Image.NEAREST)
|
|
legend_img.paste(icon, (margin, y), icon)
|
|
# Draw block name
|
|
text_x = margin + entry_icon_size + 10
|
|
draw.text((text_x, y + (entry_height - font_size) // 2), block_name, fill=(0, 0, 0, 255), font=font)
|
|
y += entry_height
|
|
|
|
# 4. Combine legend and grid image side by side
|
|
total_height = max(legend_img.height, grid_image.height)
|
|
total_width = legend_img.width + grid_image.width
|
|
combined_img = Image.new('RGBA', (total_width, total_height), (255, 255, 255, 0))
|
|
combined_img.paste(legend_img, (0, 0), legend_img)
|
|
combined_img.paste(grid_image, (legend_img.width, 0), grid_image)
|
|
|
|
return combined_img
|
|
|