pythonchartsgraphvizanytree

How can I create a stylized tree chart?


I have been making a tree of fraternity-adjacent bigs and littles and was looking for a way to automate it for changes as more people join. Everyone's names and years, big, and littles are in an Excel spreadsheet. What could I use to emulate the design I did here? Specifically, the stem style and ability to space nodes further away depending on their year.

This is the design I want to automate:

The tree I want to emulate

I tried using anytree and graphviz but couldn't find a way to emulate the stems or an easy solution for spacing based on years.

Here's sample data:

Name Year Instrument Extra Extra Extra Extra Big Little 1 Little 2 Little 3
T1P1 1990 Trumpet T1P2
T1P2 1991 Trumpet T1P1
T2P1 1997 Trumpet T2P2
T2P2 2001 Trumpet T2P1 T2P3 T2P4 T2P5
T2P3 2003 Trumpet T2P2
T2P4 2004 Trumpet T2P2
T2P5 2006 Trumpet T2P2
T3P1 2000 Trumpet T3P2
T3P2 2004 Trumpet T3P1 T3P3 T3P4
T3P3 2005 Trumpet T3P2 T3P5 T3P6
T3P5 2006 Trumpet T3P3
T3P6 2007 Trumpet T3P3
T3P4 2006 Trumpet T3P2 T3P7
T3P7 2010 Flute T3P4

Here's my basic approach using anytree and the results:

import openpyxl
from PIL import Image, ImageDraw, ImageFont
import re
from anytree import Node, RenderTree
from collections import Counter
import os

# Create a directory to store the individual name card images
cards_dir = "C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/cards"
os.makedirs(cards_dir, exist_ok=True)

# Load the .xlsx file
file_path = 'C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/sampletrees.xlsx'
workbook = openpyxl.load_workbook(file_path)
sheet = workbook.active

# Read the data starting from row 2 to the last row with data (max_row) in columns A to N
people_data = []
for row in sheet.iter_rows(min_row=2, max_row=sheet.max_row, min_col=1, max_col=14):
    person_info = [cell.value for cell in row]
    people_data.append(person_info)

# Tree Data Making
# Dictionary to hold people by their names
people_dict = {}

# List to hold the root nodes of multiple trees
root_nodes = []

# Sets to track parents and children
parents_set = set()
children_set = set()

# Dictionary to track parent-child relationships for conflict detection
parent_child_relationships = {}

# List to store the individual trees as objects
family_trees = []  # List to hold each separate family tree

# Iterate over the people data and create nodes for each person
for i, person_info in enumerate(people_data, start=2):  # i starts at 2 for row index
    name = person_info[0]  # Assuming name is in the first column (column A)
    column_b_data = person_info[1]  # Column B data (second column)
    parent_name = person_info[7]  # Column H for parent (8th column)
    children_names = person_info[8:14]  # Columns I to N for children (9th to 14th columns)

    # Check if this name is already in the people_dict
    if name not in people_dict:
        # Create the person node (this is the current node) without column B info at this point
        person_node = Node(name)  # Create the person node with just the name

        # If parent_name is empty, this is a root node for a new tree
        if parent_name:
            if parent_name in people_dict:
                parent_node = people_dict[parent_name]
            else:
                parent_node = Node(parent_name)
                people_dict[parent_name] = parent_node  # Add the parent to the dictionary

            person_node.parent = parent_node  # Set the parent for the current person
            # Add to the parents set
            parents_set.add(parent_name)
        else:
            # If no parent is referenced, this could be the root or top-level node
            root_nodes.append(person_node)  # Add to root_nodes list

        # Store the person node in the dictionary (this ensures we don't create duplicates)
        people_dict[name] = person_node

        # Create child nodes for the person and add them to the children set
        for child_name in children_names:
            if child_name:
                # Create child node without modifying its name with additional info from the parent
                if child_name not in people_dict:
                    child_node = Node(child_name, parent=person_node)
                    people_dict[child_name] = child_node  # Store the child in the dictionary
                children_set.add(child_name)

                # Add the parent-child relationship for conflict checking
                if child_name not in parent_child_relationships:
                    parent_child_relationships[child_name] = set()
                parent_child_relationships[child_name].add(name)

# Print out the family trees for each root node (disconnected trees)
for root_node in root_nodes:
    family_tree = []
    for pre, fill, node in RenderTree(root_node):
        family_tree.append(f"{pre}{node.name}")
    family_trees.append(family_tree)  # Save each tree as a separate list of names
    print(f"\nFamily Tree starting from {root_node.name}:")
    for pre, fill, node in RenderTree(root_node):
        print(f"{pre}{node.name}")

# Tree Chart Making
# Extract the years from the first four characters in Column B
years = []
for person_info in people_data:
    column_b_data = person_info[1]
    if column_b_data:
        year_str = str(column_b_data)[:4]
        if year_str.isdigit():
            years.append(int(year_str))

# Calculate the range of years (from the minimum year to the maximum year)
min_year = min(years) if years else 0
max_year = max(years) if years else 0
year_range = max_year - min_year + 1 if years else 0

# Create a base image with a solid color (header space)
base_width = 5000
base_height = 300 + (100 * year_range)  # Header (300px) + layers of 100px strips based on the year range
base_color = "#B3A369"
base_image = Image.new("RGB", (base_width, base_height), color=base_color)

# Create a drawing context
draw = ImageDraw.Draw(base_image)

# Define the text and font for the header
text = "The YJMB Trumpet Section Family Tree"
font_path = "C:/Windows/Fonts/calibrib.ttf"
font_size = 240
font = ImageFont.truetype(font_path, font_size)

# Get the width and height of the header text using textbbox
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]

# Calculate the position to center the header text horizontally
x = (base_width - text_width) // 2
y = (300 - text_height) // 2  # Vertically center the text in the first 300px

# Add the header text to the image
draw.text((x, y), text, font=font, fill=(255, 255, 255))

# List of colors for the alternating strips
colors = ["#FFFFFF", "#003057", "#FFFFFF", "#B3A369"]
strip_height = 100

# Font for the year text
year_font_size = 60
year_font = ImageFont.truetype(font_path, year_font_size)

# Add the alternating colored strips beneath the header
y_offset = 300  # Start just below the header text
for i in range(year_range):
    strip_color = colors[i % len(colors)]

    # Draw the strip
    draw.rectangle([0, y_offset, base_width, y_offset + strip_height], fill=strip_color)

    # Calculate the text to display (the year for this strip)
    year_text = str(min_year + i)

    # Get the width and height of the year text using textbbox
    bbox = draw.textbbox((0, 0), year_text, font=year_font)
    year_text_width = bbox[2] - bbox[0]
    year_text_height = bbox[3] - bbox[1]

    # Calculate the position to center the year text vertically on the strip
    year_text_x = 25  # Offset 25px from the left edge
    year_text_y = y_offset + (strip_height - year_text_height) // 2 - 5  # Vertically center the text

    # Determine the text color based on the strip color
    year_text_color = "#003057" if strip_color == "#FFFFFF" else "white"

    # Add the year text to the strip
    draw.text((year_text_x, year_text_y), year_text, font=year_font, fill=year_text_color)

    # Move the offset for the next strip
    y_offset += strip_height

# Font for the names on the name cards (reduced to size 22)
name_font_size = 22
name_font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", name_font_size)

# Initialize counters for each year (based on the range of years)
year_counters = {year: 0 for year in range(min_year, max_year + 1)}

# Create a list of names from the spreadsheet, split on newlines where appropriate
for i, person_info in enumerate(people_data):
    name = person_info[0]  # Assuming name is in the first column (column A)
    original_name = name
    column_b_data = person_info[1]  # Column B data (second column)
    column_c_data = person_info[2]  # Column C data (third column)

    # Choose the correct name card template based on Column C
    if column_c_data and "Trumpet" not in column_c_data:
        # Use the blue name card template if Column C doesn't include "Trumpet"
        name_card_template = Image.open("C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/blank_blue_name_card.png")
    else:
        # Use the default name card template if Column C includes "Trumpet"
        name_card_template = Image.open("C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/blank_name_card.png")

    if column_b_data:
        year_str = str(column_b_data)[:4]
        if year_str.isdigit():
            year = int(year_str)
            year_index = year - min_year  # Find the corresponding year index (from 0 to year_range-1)

            person_node.year = year

            person_node.name = name

            # Check if the name contains "VET" or "RAT"
            if "VET" in name or "RAT" in name:
                # Replace the first space with a newline
                name_lines = name.split(' ', 1)
                name = name_lines[0] + '\n' + name_lines[1]
            elif name == "Special Case":
                # Special case for "Special Case"
                name_lines = name.split('-')
                name = name_lines[0] + '\n' + name_lines[1]  # Add newline after the hyphen
            else:
                # Split on the last space if it doesn't contain "VET" or "RAT"
                name_lines = name.split(' ')
                if len(name_lines) > 1:
                    name = ' '.join(name_lines[:-1]) + '\n' + name_lines[-1]
                else:
                    name_lines = [name]

            # Create a copy of the name card for each person
            name_card_copy = name_card_template.copy()
            card_draw = ImageDraw.Draw(name_card_copy)

            # Calculate the total height of all the lines combined (with some padding between lines)
            line_heights = []
            total_text_height = 0
            for line in name.split('\n'):
                line_bbox = card_draw.textbbox((0, 0), line, font=name_font)
                line_height = line_bbox[3] - line_bbox[1]
                line_heights.append(line_height)
                total_text_height += line_height

            # Shift the text up by 8 pixels and calculate the vertical starting position
            start_y = (name_card_template.height - total_text_height) // 2 - 6  # Shifted up by 8px

            # Draw each line centered horizontally
            current_y = start_y
            first_line_raised = False  # To track if the first line has 'gjpqy' characters
            for i, line in enumerate(name.split('\n')):
                line_bbox = card_draw.textbbox((0, 0), line, font=name_font)
                line_width = line_bbox[2] - line_bbox[0]

                # Calculate the horizontal position to center this line
                line_x = (name_card_template.width - line_width) // 2

                # Draw the line at the correct position
                card_draw.text((line_x, current_y), line, font=name_font, fill="black")

                if i == 0 and any(char in line for char in 'gjpqy'):
                    # If the first line contains any of the letters, lower it by 7px (5px padding + 2px extra)
                    current_y += line_heights[i] + 7  # 5px for space, 2px additional for g, j, p, q, y
                    first_line_raised = True
                elif i == 0:
                    # If the first line doesn't contain those letters, add 7px space
                    current_y += line_heights[i] + 7
                else:
                    # For subsequent lines, add the usual space
                    if first_line_raised:
                        # If first line was adjusted for 'gjpqy', raise second line by 2px
                        current_y += line_heights[i] - 2  # Raise second line by 2px
                    else:
                        current_y += line_heights[i] + (5 if i == 0 else 0)


            # Position for the name card in the appropriate year strip
            card_x = 25 + year_text_x + year_text_width  # 25px to the right of the year text
            card_y = 300 + (strip_height * year_index) + (strip_height - name_card_template.height) // 2  # Vertically center in the strip based on year

            # Assign card and y position attributes to each person
            person_node.card = name_card_copy
            person_node.y = card_y
            # print(person_node.y)

            # Use the counter for the corresponding year to determine x_offset
            x_offset = card_x + year_counters[year] * 170  # Add offset for each subsequent name card
            year_counters[year] += 1  # Increment the counter for this year
            # print(f"{year_counters[year]}")

            card_file_path = os.path.join(cards_dir, f"{original_name}.png")
            person_node.card.save(card_file_path)

            # Paste the name card onto the image at the calculated position
            base_image.paste(name_card_copy, (x_offset, person_node.y), name_card_copy)

# Save the final image with name cards
base_image.save("final_image_with_name_cards_updated.png")
base_image.show()

example of output mirroring background aestetics of original work

Here's my approach with graphviz:

import openpyxl
from anytree import Node, RenderTree
import os
from graphviz import Digraph
from PIL import Image

# Create a directory to store the family tree images
trees_dir = "C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/trees"
cards_dir = "C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/cards"
os.makedirs(trees_dir, exist_ok=True)

# Load the .xlsx file
file_path = 'C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/sampletrees.xlsx'
workbook = openpyxl.load_workbook(file_path)
sheet = workbook.active

# Read the data starting from row 2 to the last row with data (max_row) in columns A to N
people_data = []
for row in sheet.iter_rows(min_row=2, max_row=sheet.max_row, min_col=1, max_col=14):
    person_info = [cell.value for cell in row]
    people_data.append(person_info)

# Tree Data Making
people_dict = {}  # Dictionary to hold people by their names
root_nodes = []  # List to hold the root nodes of multiple trees
parents_set = set()  # Sets to track parents and children
children_set = set()
parent_child_relationships = {}  # Dictionary to track parent-child relationships

# Create nodes for each person
for i, person_info in enumerate(people_data, start=2):  # i starts at 2 for row index
    name = person_info[0]
    parent_name = person_info[7]
    children_names = person_info[8:14]  # Columns I to N for children

    if name not in people_dict:
        person_node = Node(name)

        # If no parent is mentioned, add as a root node
        if parent_name:
            parent_node = people_dict.get(parent_name, Node(parent_name))
            people_dict[parent_name] = parent_node  # Add the parent to the dictionary
            person_node.parent = parent_node  # Set the parent for the current person
            parents_set.add(parent_name)
        else:
            root_nodes.append(person_node)

        people_dict[name] = person_node  # Store the person node

        # Create child nodes for the person
        for child_name in children_names:
            if child_name:
                if child_name not in people_dict:
                    child_node = Node(child_name, parent=person_node)
                    people_dict[child_name] = child_node
                children_set.add(child_name)

                if child_name not in parent_child_relationships:
                    parent_child_relationships[child_name] = set()
                parent_child_relationships[child_name].add(name)

# Function to generate the family tree graph using Graphviz
def generate_tree_graph(root_node):
    graph = Digraph(format='png', engine='dot', strict=True)

    def add_node_edges(node):
        # Image file path
        image_path = os.path.join(cards_dir, f"{node.name}.png")  # Assuming each person has a PNG image named after them

        if os.path.exists(image_path):
            # If the image exists, replace the node with the image, and remove any text label
            graph.node(node.name, image=image_path, shape="none", label='')
        else:
            # Fallback to text if no image is found (this can be further adjusted if needed)
            graph.node(node.name, label=node.name, shape='rect')

        # Add edges (parent-child relationships)
        if node.parent:
            graph.edge(node.parent.name, node.name)
        
        for child in node.children:
            add_node_edges(child)

    add_node_edges(root_node)
    return graph

# Generate and save tree images
tree_images = []
for root_node in root_nodes:
    tree_graph = generate_tree_graph(root_node)
    tree_image_path = os.path.join(trees_dir, f"{root_node.name}_family_tree")
    tree_graph.render(tree_image_path, format='png')
    tree_images.append(tree_image_path)

# Resize all tree images to be the same size
target_width = 800  # Target width for each tree image
target_height = 600  # Target height for each tree image
resized_images = []

for image_path in tree_images:
    image = Image.open(f"{image_path}.png")
    resized_images.append(image)

# Create a new image large enough to hold all resized tree images side by side
total_width = target_width * len(resized_images)
max_height = max(image.height for image in resized_images)

# Create a blank white image to paste the resized trees into
combined_image = Image.new('RGB', (total_width, max_height), color='white')

# Paste each resized tree image into the combined image
x_offset = 0
for image in resized_images:
    combined_image.paste(image, (x_offset, 0))
    x_offset += image.width

# Save the final combined image as a single PNG file
combined_image_path = 'C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/final_combined_family_tree.png'
combined_image.save(combined_image_path)

# Show the final combined image
combined_image.show()

example of output showing trees using proper node visuals


Solution

  • Solved it.

    from anytree import Node, RenderTree
    from collections import Counter
    import os
    import openpyxl
    from PIL import Image, ImageDraw, ImageFont
    import re
    
    # Create a directory to store the individual name card images
    cards_dir = "C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/cards"
    os.makedirs(cards_dir, exist_ok=True)
    
    # Load the .xlsx file
    file_path = 'C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/YJMB Trumpet Trees.xlsx'
    workbook = openpyxl.load_workbook(file_path)
    sheet = workbook.active
    
    # Read the data starting from row 2 to the last row with data (max_row) in columns A to N
    people_data = []
    for row in sheet.iter_rows(min_row=2, max_row=sheet.max_row, min_col=1, max_col=14):
        person_info = [cell.value for cell in row]
        people_data.append(person_info)
    
    # Tree Data Making
    # Dictionary to hold people by their names
    people_dict = {}
    
    # List to hold the root nodes of multiple trees
    root_nodes = []
    
    # Sets to track parents and children
    parents_set = set()
    children_set = set()
    
    # Dictionary to track parent-child relationships for conflict detection
    parent_child_relationships = {}
    
    # List to store the individual trees as objects
    family_trees = []  # List to hold each separate family tree
    
    # Variable to track the current tree number
    tree_number = 0  # Start with tree 1
    
    # A counter for nodes without children
    end_id_counter = 1
    
    years = []
    x_max = 0
    
    # Iterate over the people data and create nodes for each person
    for i, person_info in enumerate(people_data, start=2):  # i starts at 2 for row index
        name = person_info[0]  # Name is in the first column (column A)
        rat_year = str(person_info[1])[:4]  # Year they joined the marching band (second column)
        if rat_year.isdigit():
            years.append(int(rat_year))
        instrument = person_info[2]
        parent_name = person_info[7]  # Column H for VET (8th column)
        children_names = person_info[8:14]  # Columns I to N for RATs (9th to 14th columns)
    
        # Determine if the node has children (if any of the children_names is non-empty)
        has_children = any(child_name for child_name in children_names if child_name)
    
        if i < len(people_data) and not person_info[7]:  # Parent is empty in that row
            tree_number += 1  # Increment tree number for the next family tree
    
        # Check if this name is already in the people_dict
        if name in people_dict:
            # If the person already exists in the dictionary, retrieve their node
            person_node = people_dict[name]
            # Update the rat_year for the existing person node if necessary
            person_node.rat_year = rat_year
            person_node.instrument = instrument
        else:
            # If the person does not exist in the dictionary, create a new node
            person_node = Node(name, tree_number=tree_number, id=0, has_children=has_children, rat_year=rat_year, x_coord=None, y_coord=None, instrument=instrument, children_nodes=[])  # Added children_nodes
    
            # If parent_name is empty, this is a root node for a new tree
            if parent_name:
                if parent_name in people_dict:
                    parent_node = people_dict[parent_name]
                else:
                    parent_node = Node(parent_name, tree_number=tree_number, id=0, has_children=False, rat_year=None, x_coord=None, y_coord=None, instrument=None, children_nodes=[])  # Added children_nodes
                    people_dict[parent_name] = parent_node  # Add the parent to the dictionary
    
                person_node.parent = parent_node  # Set the parent for the current person
                parents_set.add(parent_name)
    
                # After setting the parent, update the parent's has_children flag
                parent_node.has_children = True  # Set has_children to True for the parent node
                parent_node.children_nodes.append(person_node)  # Add to parent's children_nodes
    
            else:
                root_nodes.append(person_node)  # Add to root_nodes list
    
            people_dict[name] = person_node  # Add the new person node to the dictionary
    
        # Now create child nodes for the given children names
        for child_name in children_names:
            if child_name:
                if child_name not in people_dict:
                    child_node = Node(child_name, parent=person_node, tree_number=tree_number, id=0, has_children=False, rat_year=rat_year, x_coord=None, y_coord=None, instrument=instrument, children_nodes=[])  # Added children_nodes
                    people_dict[child_name] = child_node
                children_set.add(child_name)
    
                # If the child node has been created, we need to ensure the parent's has_children flag is True
                person_node.has_children = True
                person_node.children_nodes.append(people_dict[child_name])  # Add child to parent's children_nodes
    
                if child_name not in parent_child_relationships:
                    parent_child_relationships[child_name] = set()
                parent_child_relationships[child_name].add(name)
    
    # After all nodes are created, we calculate x and y coordinates for each node
    new_id = 1
    start_x_coord = 200
    curr_tree = 1
    
    min_year = min(years) if years else 0
    max_year = max(years) if years else 0
    year_range = max_year - min_year + 1 if years else 0
    
    end_id_counter = 1
    
    # Print out the family trees for each root node (disconnected trees)
    for root_node in root_nodes:
        family_tree = []
        for pre, fill, node in RenderTree(root_node):
            family_tree.append(f"{pre}{node.name}")
        family_trees.append(family_tree)
        # print(f"\nFamily Tree starting from {root_node.name}:")
        for pre, fill, node in RenderTree(root_node):
            node.id = new_id
            new_id += 1
            if not node.has_children:
                new_tree = node.tree_number
                if new_tree != curr_tree:
                    start_x_coord += 200
                curr_tree = node.tree_number
                node.end_id = end_id_counter
                end_id_counter += 1
                node.x_coord = start_x_coord
                start_x_coord += 170
            else:
                node.end_id = 0
            if getattr(node, 'x_coord', 'N/A') and getattr(node, 'x_coord', 'N/A') > x_max:
                x_max = node.x_coord
    
            # Print details for each node
            # print(f"{pre}{node.name} (ID: {node.id}, Tree Number: {node.tree_number}, Has Children: {node.has_children}, End ID: {getattr(node, 'end_id', 'N/A')}, X Coord: {getattr(node, 'x_coord', 'N/A')}, Y Coord: {getattr(node, 'y_coord', 'N/A')}, Rat Year: {getattr(node, 'rat_year', 'N/A')}, Instrument: {getattr(node, 'children_nodes', 'N/A')})")
    
    # Now assign X coordinates to nodes where X is None (based on their children)
    while any(node.x_coord is None for node in people_dict.values()):
        for node in people_dict.values():
            if node.has_children:
                children_with_coords = [child for child in node.children if child.x_coord is not None]
                if len(children_with_coords) == len(node.children):  # Check if all children have x_coord
                    average_x_coord = sum(child.x_coord for child in children_with_coords) / len(children_with_coords)
                    node.x_coord = round(average_x_coord)  # Set the parent's x_coord to the average
    
    # Print out the family trees for each root node (disconnected trees)
    for root_node in root_nodes:
        family_tree = []
        for pre, fill, node in RenderTree(root_node):
            family_tree.append(f"{pre}{node.name}")
        family_trees.append(family_tree)
        # print(f"\nFamily Tree starting from {root_node.name}:")
        # for pre, fill, node in RenderTree(root_node):
            # print(f"{pre}{node.name} (ID: {node.id}, Tree Number: {node.tree_number}, Has Children: {node.has_children}, End ID: {getattr(node, 'end_id', 'N/A')},  Children Nodes: {getattr(node, 'children_nodes', 'N/A')})")
    
    
    # fix the rat_year attribute for even-numbered generations (done)
    # use that to determine y value (done)
    # determine x values from the bottom up recursively (done)
    
    
    
    
    # # Print duplicate ids, if any
    # if duplicates:
    #     print("\nDuplicate IDs found:", duplicates)
    # else:
    #     print("\nNo duplicates found.")
    
    
    #----------------------------------------------------------#
    
    
    # Tree Chart Making
    
    # Extract the years from the first four characters in Column B (done in lines 51-53 now)
    # Calculate the range of years (from the minimum year to the maximum year) (107-109)
    # Create a base image with a solid color (header space)
    base_width = x_max + 200
    base_height = 300 + (100 * year_range)  # Header (300px) + layers of 100px strips based on the year range
    base_color = "#B3A369"
    base_image = Image.new("RGB", (base_width, base_height), color=base_color)
    
    # Create a drawing context
    draw = ImageDraw.Draw(base_image)
    
    # Define the text and font for the header
    text = "The YJMB Trumpet Section Family Tree"
    font_path = "C:/Windows/Fonts/calibrib.ttf"
    font_size = 240
    font = ImageFont.truetype(font_path, font_size)
    
    # Get the width and height of the header text using textbbox
    bbox = draw.textbbox((0, 0), text, font=font)
    text_width = bbox[2] - bbox[0]
    text_height = bbox[3] - bbox[1]
    
    # Calculate the position to center the header text horizontally
    x = (base_width - text_width) // 2
    y = (300 - text_height) // 2  # Vertically center the text in the first 300px
    
    # Add the header text to the image
    draw.text((x, y), text, font=font, fill=(255, 255, 255))
    
    # List of colors for the alternating strips
    colors = ["#FFFFFF", "#003057", "#FFFFFF", "#B3A369"]
    strip_height = 100
    
    # Font for the year text
    year_font_size = 60
    year_font = ImageFont.truetype(font_path, year_font_size)
    
    # Add the alternating colored strips beneath the header
    y_offset = 300  # Start just below the header text
    for i in range(year_range):
        strip_color = colors[i % len(colors)]
    
        # Draw the strip
        draw.rectangle([0, y_offset, base_width, y_offset + strip_height], fill=strip_color)
    
        # Calculate the text to display (the year for this strip)
        year_text = str(min_year + i)
    
        # Get the width and height of the year text using textbbox
        bbox = draw.textbbox((0, 0), year_text, font=year_font)
        year_text_width = bbox[2] - bbox[0]
        year_text_height = bbox[3] - bbox[1]
    
        # Calculate the position to center the year text vertically on the strip
        year_text_x = 25  # Offset 25px from the left edge
        year_text_y = y_offset + (strip_height - year_text_height) // 2 - 5  # Vertically center the text
    
        # Determine the text color based on the strip color
        year_text_color = "#003057" if strip_color == "#FFFFFF" else "white"
    
        # Add the year text to the strip
        draw.text((year_text_x, year_text_y), year_text, font=year_font, fill=year_text_color)
    
        # Move the offset for the next strip
        y_offset += strip_height
    
    # Font for the names on the name cards (reduced to size 22)
    name_font_size = 22
    name_font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", name_font_size)
    
    # Initialize counters for each year (based on the range of years)
    year_counters = {year: 0 for year in range(min_year, max_year + 1)}
    
    # Create a list of names from the spreadsheet, split on newlines where appropriate
    for node in people_dict.values():
        # Choose the correct name card template based on Column C
        if node.instrument and "Trumpet" not in node.instrument:
            name_card_template = Image.open("C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/blank_blue_name_card.png")
        else:
            name_card_template = Image.open("C:/Users/Chris Fitz/Documents/Fun/Trumpet History/trumpettree/blank_name_card.png")
    
        if node.rat_year:
            year_string = str(node.rat_year)[:4]
            if year_string.isdigit():
                year = int(year_string)
                year_index = year - min_year  # Find the corresponding year index (from 0 to year_range-1)
    
                name = node.name
    
                # Check if the name contains "VET" or "RAT"
                if "VET" in name or "RAT" in name:
                    name_lines = name.split(' ', 1)
                    name = name_lines[0] + '\n' + name_lines[1]
                elif name == "Xxx Xxxxxx-Xxxxxxx":
                    name_lines = name.split('-')
                    name = name_lines[0] + '\n' + name_lines[1]  # Add newline after the hyphen
                else:
                    name_lines = name.split(' ')
                    if len(name_lines) > 1:
                        name = ' '.join(name_lines[:-1]) + '\n' + name_lines[-1]
                    else:
                        name_lines = [name]
    
                # Create a copy of the name card for each person
                name_card_copy = name_card_template.copy()
                card_draw = ImageDraw.Draw(name_card_copy)
    
                # Calculate the total height of all the lines combined (with some padding between lines)
                line_heights = []
                total_text_height = 0
                for line in name.split('\n'):
                    line_bbox = card_draw.textbbox((0, 0), line, font=name_font)
                    line_height = line_bbox[3] - line_bbox[1]
                    line_heights.append(line_height)
                    total_text_height += line_height
    
                # Shift the text up by 8 pixels and calculate the vertical starting position
                start_y = (name_card_template.height - total_text_height) // 2 - 6  # Shifted up by 8px
    
                # Draw each line centered horizontally
                current_y = start_y
                first_line_raised = False
                for i, line in enumerate(name.split('\n')):
                    line_bbox = card_draw.textbbox((0, 0), line, font=name_font)
                    line_width = line_bbox[2] - line_bbox[0]
                    line_x = (name_card_template.width - line_width) // 2
                    card_draw.text((line_x, current_y), line, font=name_font, fill="black")
    
                    if i == 0 and any(char in line for char in 'gjpqy'):
                        current_y += line_heights[i] + 7
                        first_line_raised = True
                    elif i == 0:
                        current_y += line_heights[i] + 7
                    else:
                        if first_line_raised:
                            current_y += line_heights[i] - 2
                        else:
                            current_y += line_heights[i] + (5 if i == 0 else 0)
    
                # Position for the name card in the appropriate year strip
                card_y = 300 + (strip_height * year_index) + (strip_height - name_card_template.height) // 2  # Vertically center in the strip based on year
                node.y_coord = card_y
    
                # Assign card and y position attributes to each person
                person_node.card = name_card_copy
                person_node.y_coord = card_y
    
                # Use the counter for the corresponding year to determine x_offset
                year_counters[year] += 1
                card_file_path = os.path.join(cards_dir, f"{node.name}.png")
                person_node.card.save(card_file_path)
    
                # Paste the name card onto the image at the calculated position
                base_image.paste(name_card_copy, (node.x_coord, node.y_coord), name_card_copy)
    
    # Create a list of names from the spreadsheet, split on newlines where appropriate
    for node in people_dict.values():
        # Add black rectangle beneath the name card if the node has children
        if node.has_children:
            if len(node.children_nodes) == 1:
                child_node = getattr(node, 'children_nodes', 'N/A')[0]  # Only one child, so get the first (and only) child
                # print(getattr(child_node, 'y_coord', 'N/A'))
    
                # Coordinates for the rectangle (centered beneath the name card)
                rect_x = node.x_coord + (name_card_template.width - 6) // 2  # Center the rectangle
                rect_y = node.y_coord + (name_card_template.height - 2)  # Just below the name card
                rect_y_bottom = int(getattr(child_node, 'y_coord', 'N/A')) + 1  # Bottom of rectangle is aligned with the y_coord of the child
    
                # Draw the rectangle
                draw.rectangle([rect_x - 1, rect_y, rect_x + 6, rect_y_bottom], fill=(111, 111, 111))
            else:
                # Calculate the leftmost and rightmost x-coordinates of the child nodes
                min_x = min(getattr(child, 'x_coord', 0) for child in node.children_nodes)
                max_x = max(getattr(child, 'x_coord', 0) for child in node.children_nodes)
    
                # Calculate the center of the rectangle (between the leftmost and rightmost child nodes)
                rect_x = (min_x + max_x) // 2  # Center x-coordinate between the children
                rect_y = (node.y_coord + min(getattr(child, 'y_coord', node.y_coord) for child in node.children_nodes)) // 2
                rect_width = max_x - min_x
                draw.rectangle([rect_x - rect_width // 2 + 75, rect_y + 36, rect_x + rect_width // 2 + 75, rect_y + 6 + 37], fill=(111, 111, 111))
    
                parent_y_bottom = rect_y + 36
    
                # Coordinates for the rectangle (centered beneath the name card)
                rect_x = node.x_coord + (name_card_template.width - 6) // 2  # Center the rectangle
                rect_y = node.y_coord + (name_card_template.height - 2)  # Just below the name card
                draw.rectangle([rect_x - 1, rect_y, rect_x + 6, parent_y_bottom], fill=(111, 111, 111))
    
                # Now create a vertical rectangle for each child node
                for child in node.children_nodes:
                    child_x = getattr(child, 'x_coord', 0)
                    child_center_x = child_x + (name_card_template.width - 6) // 2  # x-center of the child
                    child_y_bottom = parent_y_bottom  # The bottom of the rectangle should align with the parent's bottom
                    
                    # Draw the rectangle from the center of the child node up to the parent's y-bottom
                    draw.rectangle([child_center_x - 1, child_y_bottom, child_center_x + 6, getattr(child, 'y_coord', 0) + 1], fill=(111, 111, 111))  # 6px wide
    
    # Print out the family trees for each root node (disconnected trees)
    for root_node in root_nodes:
        family_tree = []
        for pre, fill, node in RenderTree(root_node):
            family_tree.append(f"{pre}{node.name}")
        family_trees.append(family_tree)
        print(f"\nFamily Tree starting from {root_node.name}:")
        for pre, fill, node in RenderTree(root_node):
        #     print(f"{pre}{node.name} (ID: {node.id}, Tree Number: {node.tree_number}, Has Children: {node.has_children}, End ID: {getattr(node, 'end_id', 'N/A')},  Children Nodes: {getattr(node, 'children_nodes', 'N/A')})")
            print(f"{pre}{node.name} (ID: {node.id}, Tree Number: {node.tree_number}, Has Children: {node.has_children}, End ID: {getattr(node, 'end_id', 'N/A')}, Y Coord: {getattr(node, 'y_coord', 'N/A')}, Children: {len(getattr(node, 'children_nodes', 'N/A'))})")
    
    # Save the final image with name cards and black rectangles
    base_image.save("YJMB_Trumpet_Section_Family_Trees_2024.png")
    base_image.show()
    

    My finished product with names redacted