July 08, 2026

Microtype simulator in python

 

import pygame
import sys
import math

# Initialize Pygame
pygame.init()
pygame.font.init()

# Constants
WIDTH, HEIGHT = 1100, 780
SCREEN = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Professional Microtype Engine & Layout Simulator")
CLOCK = pygame.time.Clock()

# Palettes
COLOR_BG = (249, 248, 245)       # Premium archival paper
COLOR_TEXT = (35, 35, 35)        # Soft black
COLOR_MARGIN = (230, 90, 90)     # Margin guideline
COLOR_UI_BG = (225, 227, 230)
COLOR_UI_TEXT = (50, 55, 60)
COLOR_SLIDER = (70, 130, 180)
COLOR_ACTIVE = (46, 139, 87)     # SeaGreen for scores/active selections

# Load fonts
try:
    FONT_SIZE = 18
    FONT = pygame.font.SysFont("georgia", FONT_SIZE)
    FONT_BOLD = pygame.font.SysFont("georgia", FONT_SIZE, bold=True)
except:
    FONT = pygame.font.Font(None, FONT_SIZE)
    FONT_BOLD = pygame.font.Font(None, FONT_SIZE)

SAMPLE_TEXT = (
    "Typography is the art and technique of arranging type to make written language "
    "legible, readable, and appealing when displayed. The Knuth-Plass dynamic programming "
    "algorithm revolutionizes this by looking ahead at the entire paragraph. Instead of "
    "making hasty choices on a line-by-line basis, it distributes layout 'badness' evenly, "
    "preventing unexpected blocks of loose text. Combined with microtype tracking expansions, "
    "subtle margin protrusions yield pristine geometric columns resembling classic elite print."
)

# --- UI Widgets ---
class Slider:
    def __init__(self, x, y, w, h, min_val, max_val, start_val, label):
        self.rect = pygame.Rect(x, y, w, h)
        self.min_val = min_val
        self.max_val = max_val
        self.val = start_val
        self.label = label
        self.grabbed = False
        self.update_handle()

    def update_handle(self):
        ratio = (self.val - self.min_val) / (self.max_val - self.min_val)
        hx = self.rect.x + int(ratio * self.rect.w)
        self.handle_rect = pygame.Rect(hx - 5, self.rect.y - 4, 10, self.rect.h + 8)

    def draw(self, screen):
        lbl = FONT.render(f"{self.label}: {self.val:.2f}", True, COLOR_UI_TEXT)
        screen.blit(lbl, (self.rect.x, self.rect.y - 22))
        pygame.draw.rect(screen, (190, 195, 200), self.rect, border_radius=3)
        pygame.draw.rect(screen, COLOR_SLIDER, self.handle_rect, border_radius=3)

    def handle_event(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN:
            if self.handle_rect.collidepoint(event.pos) or self.rect.collidepoint(event.pos):
                self.grabbed = True
        elif event.type == pygame.MOUSEBUTTONUP:
            self.grabbed = False
        elif event.type == pygame.MOUSEMOTION and self.grabbed:
            mx = max(self.rect.x, min(event.pos[0], self.rect.x + self.rect.w))
            rel = (mx - self.rect.x) / self.rect.w
            self.val = self.min_val + rel * (self.max_val - self.min_val)
            self.update_handle()

class RadioSelector:
    def __init__(self, x, y, options):
        self.x = x
        self.y = y
        self.options = options
        self.selected_index = 1 # Default to Knuth-Plass
        self.buttons = []
        
        for idx, opt in enumerate(options):
            bx = x + (idx * 280)
            self.buttons.append(pygame.Rect(bx, y, 20, 20))

    def draw(self, screen):
        lbl_title = FONT_BOLD.render("Line Breaking Algorithm:", True, COLOR_UI_TEXT)
        screen.blit(lbl_title, (self.x, self.y - 25))
        
        for idx, opt in enumerate(self.options):
            rect = self.buttons[idx]
            # Draw outer circle
            pygame.draw.circle(screen, COLOR_UI_TEXT, rect.center, 10, 2)
            # Draw internal selection
            if idx == self.selected_index:
                pygame.draw.circle(screen, COLOR_ACTIVE, rect.center, 6)
            
            lbl = FONT.render(opt, True, COLOR_UI_TEXT)
            screen.blit(lbl, (rect.x + 25, rect.y + 1))

    def handle_event(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN:
            for idx, rect in enumerate(self.buttons):
                # Expanded click zone for user convenience
                click_zone = rect.inflate(150, 10)
                if click_zone.collidepoint(event.pos):
                    self.selected_index = idx
                    return True
        return False

# --- Helper Text Calculation Tools ---
def compute_word_widths(words, font, tracking):
    return [sum(font.size(char)[0] + tracking for char in word) for word in words]

def calc_line_badness(width, test_width, num_gaps, base_space_width, min_space, max_space, ideal_space, is_last=False):
    if num_gaps == 0:
        remaining = width - test_width
        return (remaining ** 2) if remaining >= 0 else 500000
    
    actual_space = (width - test_width) / num_gaps
    
    if actual_space < min_space:
        # Heavily penalize over-compressed lines
        return 100000 + (min_space - actual_space) * 50000
    elif actual_space > max_space:
        # Loose lines
        return int(((actual_space - max_space) ** 2) * 500)
    else:
        # Standard deviation penalty
        badness = int(((actual_space - ideal_space) ** 2) * 100)
        if is_last and actual_space > ideal_space:
            return 0 # Last line of a paragraph shouldn't stretch to fill the margin
        return badness

def apply_protrusion(word, font, protrusion):
    protruding_chars = [".", ",", "-", "!", "?"]
    if protrusion > 0 and word[-1:] in protruding_chars:
        return font.size(word[-1:])[0] * protrusion * 0.5
    return 0

# --- Line-Breaking Core Algorithms ---

def layout_greedy(words, word_widths, font, width, min_space, max_space, ideal_space, protrusion):
    """ a) Traditional First-Fit Greedy Algorithm """
    lines = []
    current_line, current_widths = [], []
    current_width = 0
    
    for idx, word in enumerate(words):
        w_width = word_widths[idx]
        p_adjust = apply_protrusion(word, font, protrusion)
        
        # Test if it fits with standard spaces
        test_w = current_width + w_width + (ideal_space if current_line else 0) - p_adjust
        if test_w <= width or not current_line:
            current_line.append(word)
            current_widths.append(w_width)
            current_width += w_width + (ideal_space if len(current_line) > 1 else 0)
        else:
            # Seal line
            num_gaps = len(current_line) - 1
            last_word_pad = apply_protrusion(current_line[-1], font, protrusion)
            pure_width = sum(current_widths)
            
            space_used = (width - (pure_width - last_word_pad)) / num_gaps if num_gaps > 0 else ideal_space
            badness = calc_line_badness(width, pure_width - last_word_pad, num_gaps, ideal_space, min_space, max_space, ideal_space)
            
            lines.append((current_line, current_widths, space_used, False, badness))
            current_line, current_widths = [word], [w_width]
            current_width = w_width
            
    if current_line:
        num_gaps = len(current_line) - 1
        last_word_pad = apply_protrusion(current_line[-1], font, protrusion)
        pure_width = sum(current_widths)
        space_used = ideal_space
        badness = calc_line_badness(width, pure_width - last_word_pad, num_gaps, ideal_space, min_space, max_space, ideal_space, is_last=True)
        lines.append((current_line, current_widths, space_used, True, badness))
        
    return lines

def layout_knuth_plass(words, word_widths, font, width, min_space, max_space, ideal_space, protrusion):
    """ b) Look-Ahead Optimization (Global Minimum Variance) """
    n = len(words)
    dp = [(float('inf'), -1, ideal_space, 0)] * (n + 1)
    dp[0] = (0, -1, ideal_space, 0)
    
    for i in range(n):
        if dp[i][0] == float('inf'): continue
        current_width = 0
        for j in range(i, n):
            current_width += word_widths[j]
            num_gaps = j - i
            is_last = (j == n - 1)
            
            p_adjust = apply_protrusion(words[j], font, protrusion)
            line_txt_w = current_width - p_adjust
            
            badness = calc_line_badness(width, line_txt_w, num_gaps, ideal_space, min_space, max_space, ideal_space, is_last)
            
            actual_space = ideal_space
            if num_gaps > 0 and not is_last:
                actual_space = (width - line_txt_w) / num_gaps

            p_cost = dp[i][0] + badness
            if p_cost < dp[j + 1][0]:
                dp[j + 1] = (p_cost, i, actual_space, badness)

    lines, curr = [], n
    while curr > 0:
        parent = dp[curr][1]
        if parent == -1: break
        is_last = (curr == n)
        lines.append((words[parent:curr], word_widths[parent:curr], dp[curr][2], is_last, dp[curr][3]))
        curr = parent
    lines.reverse()
    return lines

def layout_first_fit_tight(words, word_widths, font, width, min_space, max_space, ideal_space, protrusion):
    """ c) Alternating Minimum Space Greedy Algorithm """
    # This variant forces as many words onto the line as physically allowed by compressing down to min_space limits.
    lines = []
    current_line, current_widths = [], []
    
    for idx, word in enumerate(words):
        w_width = word_widths[idx]
        current_line.append(word)
        current_widths.append(w_width)
        
        p_adjust = apply_protrusion(word, font, protrusion)
        num_gaps = len(current_line) - 1
        min_needed = sum(current_widths) + (num_gaps * min_space) - p_adjust
        
        if min_needed > width and num_gaps > 0:
            # Overfilled line, dump the last token to the next row
            popped_word = current_line.pop()
            popped_width = current_widths.pop()
            
            num_gaps = len(current_line) - 1
            last_word_pad = apply_protrusion(current_line[-1], font, protrusion)
            pure_width = sum(current_widths)
            
            space_used = (width - (pure_width - last_word_pad)) / num_gaps if num_gaps > 0 else ideal_space
            badness = calc_line_badness(width, pure_width - last_word_pad, num_gaps, ideal_space, min_space, max_space, ideal_space)
            
            lines.append((current_line, current_widths, space_used, False, badness))
            current_line, current_widths = [popped_word], [popped_width]
            
    if current_line:
        num_gaps = len(current_line) - 1
        last_word_pad = apply_protrusion(current_line[-1], font, protrusion)
        pure_width = sum(current_widths)
        badness = calc_line_badness(width, pure_width - last_word_pad, num_gaps, ideal_space, min_space, max_space, ideal_space, is_last=True)
        lines.append((current_line, current_widths, ideal_space, True, badness))
        
    return lines

# --- Rendering ---
def render_paragraph(lines, font, x_start, y_start, tracking, protrusion, leading_ratio):
    y = y_start
    line_height = int(font.get_linesize() * leading_ratio)
    
    for line_words, line_widths, space_width, is_last, _ in lines:
        x = x_start
        num_words = len(line_words)
        
        for w_idx, word in enumerate(line_words):
            for c_idx, char in enumerate(word):
                char_surf = font.render(char, True, COLOR_TEXT)
                render_x = x
                if w_idx == num_words - 1 and c_idx == len(word) - 1:
                    render_x += apply_protrusion(word, font, protrusion)

                SCREEN.blit(char_surf, (render_x, y))
                x += char_surf.get_width() + tracking
            
            if w_idx < num_words - 1:
                x += space_width
        y += line_height

# --- UI Layout ---
sliders = [
    Slider(50, 540, 260, 10, -1.5, 3.0, 0.0, "Font Expansion (Tracking)"),
    Slider(380, 540, 260, 10, 0.4, 1.0, 0.65, "Min Word Space Elasticity"),
    Slider(710, 540, 260, 10, 1.0, 3.0, 1.70, "Max Word Space Elasticity"),
    Slider(50, 620, 260, 10, 0.0, 1.2, 0.5, "Character Protrusion"),
    Slider(380, 620, 260, 10, 0.8, 2.5, 1.3, "Line Height (Leading)")
]

algo_radio = RadioSelector(50, 710, ["a) Greedy Algorithm", "b) Knuth-Plass Ahead", "c) Space-Tight Fit"])

MARGIN_LEFT = 200
BOX_WIDTH = 700

# Main loop
while True:
    SCREEN.fill(COLOR_BG)
    
    # Event Engine Loop
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
        for slider in sliders:
            slider.handle_event(event)
        algo_radio.handle_event(event)

    # Drawing background infrastructure boundaries
    pygame.draw.rect(SCREEN, COLOR_UI_BG, (0, 480, WIDTH, HEIGHT - 480))
    pygame.draw.line(SCREEN, (190, 195, 200), (0, 480), (WIDTH, 480), 2)
    
    pygame.draw.line(SCREEN, COLOR_MARGIN, (MARGIN_LEFT, 75), (MARGIN_LEFT, 450), 1)
    pygame.draw.line(SCREEN, COLOR_MARGIN, (MARGIN_LEFT + BOX_WIDTH, 75), (MARGIN_LEFT + BOX_WIDTH, 450), 1)

    # Gather metrics
    base_space_width = FONT.size(" ")[0]
    tracking_val = sliders[0].val
    min_space = base_space_width * sliders[1].val
    max_space = base_space_width * sliders[2].val
    protrusion_val = sliders[3].val
    leading_val = sliders[4].val

    # Re-tokenize and check widths inside runtime
    words = SAMPLE_TEXT.split(" ")
    word_widths = compute_word_widths(words, FONT, tracking_val)

    # Route processing via radio flag selections
    if algo_radio.selected_index == 0:
        computed_lines = layout_greedy(words, word_widths, FONT, BOX_WIDTH, min_space, max_space, base_space_width, protrusion_val)
    elif algo_radio.selected_index == 1:
        computed_lines = layout_knuth_plass(words, word_widths, FONT, BOX_WIDTH, min_space, max_space, base_space_width, protrusion_val)
    else:
        computed_lines = layout_first_fit_tight(words, word_widths, FONT, BOX_WIDTH, min_space, max_space, base_space_width, protrusion_val)

    # Cumulative Badness Score Calculation
    total_paragraph_badness = sum(line[4] for line in computed_lines)

    # Render Paragraph Blocks
    render_paragraph(computed_lines, FONT, MARGIN_LEFT, 95, tracking_val, protrusion_val, leading_val)

    # Render Widgets
    for slider in sliders:
        slider.draw(SCREEN)
    algo_radio.draw(SCREEN)

    # Display Badness score at the top panel
    score_lbl = FONT_BOLD.render(f"Overall Paragraph Badness Score: {total_paragraph_badness}", True, COLOR_ACTIVE)
    SCREEN.blit(score_lbl, (MARGIN_LEFT, 35))

    pygame.display.flip()
    CLOCK.tick(30)

No comments:

Post a Comment