Source code for piltext.ascii_art

"""
ASCII art image conversion.

This module provides utilities for converting PIL images to ASCII art
representations. It supports both colored (using ANSI escape codes) and
monochrome ASCII output.

Examples
--------
Convert an image to colored ASCII art:

>>> from PIL import Image
>>> from piltext.ascii_art import display_as_ascii
>>> img = Image.open("example.png")
>>> ascii_art = display_as_ascii(img, columns=80)
>>> print(ascii_art)

Convert to monochrome ASCII art:

>>> ascii_art = display_as_ascii(img, columns=60, monochrome=True)
>>> print(ascii_art)

Use custom characters:

>>> ascii_art = display_as_ascii(img, char="█▓▒░ ", columns=100)
>>> print(ascii_art)
"""

from typing import Any, Optional, Union

from PIL import Image

PALETTE: list[list[Any]] = [
    [(0.0, 0.0, 0.0), "\033[30m", "#000000"],
    [(0.5, 0.0, 0.0), "\033[31m", "#800000"],
    [(0.0, 0.5, 0.0), "\033[32m", "#008000"],
    [(0.5, 0.5, 0.0), "\033[33m", "#808000"],
    [(0.0, 0.0, 0.5), "\033[34m", "#000080"],
    [(0.5, 0.0, 0.5), "\033[35m", "#800080"],
    [(0.0, 0.5, 0.5), "\033[36m", "#008080"],
    [(0.75, 0.75, 0.75), "\033[37m", "#c0c0c0"],
    [(0.5, 0.5, 0.5), "\033[90m", "#808080"],
    [(1.0, 0.0, 0.0), "\033[91m", "#ff0000"],
    [(0.0, 1.0, 0.0), "\033[92m", "#00ff00"],
    [(1.0, 1.0, 0.0), "\033[93m", "#ffff00"],
    [(0.0, 0.0, 1.0), "\033[94m", "#0000ff"],
    [(1.0, 0.0, 1.0), "\033[95m", "#ff00ff"],
    [(0.0, 1.0, 1.0), "\033[96m", "#00ffff"],
    [(1.0, 1.0, 1.0), "\033[97m", "#ffffff"],
]


def _l2_min(v1: list, v2: list) -> float:
    return float((v1[0] - v2[0]) ** 2 + (v1[1] - v2[1]) ** 2 + (v1[2] - v2[2]) ** 2)


def _hex_to_ansi(hex_color: str) -> str:
    """
    Convert hex color (#RRGGBB) to closest ANSI color code.

    Parameters
    ----------
    hex_color : str
        Hex color string in format #RRGGBB.

    Returns
    -------
    str
        ANSI escape code for the closest matching color.
    """
    hex_color = hex_color.lstrip("#")
    r = int(hex_color[0:2], 16)
    g = int(hex_color[2:4], 16)
    b = int(hex_color[4:6], 16)

    min_distance = float("inf")
    best_code: str = "\033[37m"

    for palette_rgb, ansi_code, _ in PALETTE:
        palette_r = int(float(palette_rgb[0]) * 255)
        palette_g = int(float(palette_rgb[1]) * 255)
        palette_b = int(float(palette_rgb[2]) * 255)

        distance = (r - palette_r) ** 2 + (g - palette_g) ** 2 + (b - palette_b) ** 2

        if distance < min_distance:
            min_distance = distance
            best_code = ansi_code

    return best_code


[docs] def display_readable_text( texts: list[str], width: int = 80, line_spacing: int = 1, center: bool = True, colors: Optional[list[Optional[Union[str, int]]]] = None, anchors: Optional[list[Optional[str]]] = None, grid_info: Optional[dict] = None, ) -> str: """ Display text content in readable ASCII format. Parameters ---------- texts : list[str] List of text strings to display. width : int, optional Width for centering text. Default is 80. line_spacing : int, optional Number of blank lines between text items. Default is 1. center : bool, optional Whether to center text. Default is True. colors : list[str or int or None], optional List of colors for each text item. Can be hex strings (#RRGGBB), integers (grayscale), or None. Default is None. anchors : list[str or None], optional List of anchor positions for each text item (e.g., 'mm', 'lt', 'rb'). Default is None. grid_info : dict, optional Grid layout information including rows, columns, and merge cells. Default is None. Returns ------- str Formatted text output with optional ANSI color codes. """ if grid_info is not None: return _display_grid_text(texts, width, colors, anchors, grid_info) if colors is None: colors_list: list[Optional[Union[str, int]]] = [None] * len(texts) else: colors_list = colors output_lines = [] for i, text in enumerate(texts): color = colors_list[i] if i < len(colors_list) else None if center: centered_text = text.center(width) else: centered_text = text if color is not None: if isinstance(color, str) and color.startswith("#"): ansi_code = _hex_to_ansi(color) output_lines.append(f"{ansi_code}{centered_text}\033[0m") elif isinstance(color, int): output_lines.append(centered_text) else: output_lines.append(centered_text) else: output_lines.append(centered_text) if i < len(texts) - 1: output_lines.extend([""] * line_spacing) return "\n".join(output_lines)
def _get_cell_position( i: int, merge_cells: list, text_items: list[dict], ) -> Optional[tuple[tuple[int, int], tuple[int, int]]]: """ Get start and end positions for a text item in the grid. """ if i < len(text_items) and "start" in text_items[i]: start_pos = tuple(text_items[i]["start"]) end_pos = text_items[i].get("end", start_pos) if isinstance(end_pos, list): end_pos = tuple(end_pos) if merge_cells: for merge in merge_cells: merge_start = tuple(merge[0]) merge_end = tuple(merge[1]) if merge_start == start_pos: return merge_start, merge_end return start_pos, end_pos return None def _align_text(text: str, cell_w: int, h_align: str) -> str: """ Align text horizontally within a cell. """ text_truncated = text[:cell_w] if len(text) > cell_w else text if h_align == "l": return text_truncated.ljust(cell_w) elif h_align == "r": return text_truncated.rjust(cell_w) else: return text_truncated.center(cell_w) def _apply_color( text: str, color: Optional[Union[str, int]], ) -> str: """ Apply ANSI color code to text if color is provided. """ if color is not None and isinstance(color, str) and color.startswith("#"): ansi_code = _hex_to_ansi(color) return f"{ansi_code}{text}\033[0m" return text def _get_text_row( v_align: str, start_row: int, end_row: int, cell_height: int, cell_h: int, ) -> int: """ Calculate the row position for text based on vertical alignment. """ if v_align == "t": return start_row * cell_height elif v_align == "b": return (end_row + 1) * cell_height - 1 else: return start_row * cell_height + cell_h // 2 def _build_grid_line( aligned_text: str, grid_row: list[str], start_col: int, end_col: int, columns: int, cell_width: int, ) -> list[str]: """ Build a single line of the grid with the aligned text. """ line_parts = [] char_idx = 0 for col in range(columns): if col >= start_col and col <= end_col: chars_in_cell = min(cell_width, len(aligned_text) - char_idx) if chars_in_cell > 0: line_parts.append(aligned_text[char_idx : char_idx + chars_in_cell]) char_idx += chars_in_cell else: line_parts.append(" " * cell_width) else: line_parts.append(grid_row[col]) return line_parts def _build_grid_line_with_borders_before_removal( aligned_text: str, grid_row: list[str], start_col: int, end_col: int, columns: int, cell_width: int, ) -> list[str]: """ Build a grid line with text before border removal. Text is aligned for content-only width (no gaps), then distributed into cells. Border removal will add gaps between cells, but text is already in the right cells. """ chars_per_cell = cell_width - 2 text_idx = 0 line_parts = [] for col in range(columns): if col >= start_col and col <= end_col: content = aligned_text[text_idx : text_idx + chars_per_cell] if len(content) < chars_per_cell: content = content.ljust(chars_per_cell) line_parts.append("|" + content + "|") text_idx += chars_per_cell else: line_parts.append(grid_row[col]) return line_parts def _build_grid_line_with_borders( text: str, grid_row: list[str], start_col: int, end_col: int, columns: int, cell_width: int, h_align: str = "m", ) -> list[str]: """ Build a single line of the grid with borders and aligned text. Grid row is AFTER border removal. Each cell has cell_width chars. For merged cells: |content | content | content| Total visual width = num_cells * cell_width Total content width = (num_cells * cell_width) - 2 (for the two border chars) """ line_parts = [] chars_per_cell = cell_width - 2 num_merged = end_col - start_col + 1 if num_merged == 1: for col in range(columns): if col == start_col: current_cell = grid_row[col] left_border = current_cell[0] right_border = current_cell[-1] aligned_text = _align_text(text, chars_per_cell, h_align) line_parts.append(left_border + aligned_text + right_border) else: line_parts.append(grid_row[col]) else: total_visual_width = num_merged * cell_width total_content_width = total_visual_width - 2 aligned_text = _align_text(text, total_content_width, h_align) text_idx = 0 for col in range(columns): if col >= start_col and col <= end_col: if col == start_col: chunk_size = cell_width - 1 chunk = aligned_text[text_idx : text_idx + chunk_size] if len(chunk) < chunk_size: chunk = chunk.ljust(chunk_size) line_parts.append("|" + chunk) text_idx += chunk_size elif col == end_col: chunk_size = cell_width - 1 chunk = aligned_text[text_idx : text_idx + chunk_size] if len(chunk) < chunk_size: chunk = chunk.ljust(chunk_size) line_parts.append(chunk + "|") text_idx += chunk_size else: chunk = aligned_text[text_idx : text_idx + cell_width] if len(chunk) < cell_width: chunk = chunk.ljust(cell_width) line_parts.append(chunk) text_idx += cell_width else: line_parts.append(grid_row[col]) return line_parts def _get_merged_regions( texts: list[str], merge_cells: list, text_items: list, ) -> dict: """ Build a dictionary mapping cell coordinates to their merged region. """ merged_regions = {} for i in range(len(texts)): position = _get_cell_position(i, merge_cells, text_items) if position is None: continue start_pos, end_pos = position start_row, start_col = start_pos end_row, end_col = end_pos for r in range(start_row, end_row + 1): for c in range(start_col, end_col + 1): merged_regions[(r, c)] = (start_row, start_col, end_row, end_col) return merged_regions def _remove_internal_borders( grid: list[list[str]], merged_regions: dict, actual_rows: int, columns: int, cell_width: int, cell_height: int, ) -> list[tuple[int, int, int, int]]: """ Remove internal borders within merged cells and return list of merged regions. Returns list of (start_row, start_col, end_row, end_col) tuples. """ processed = set() merged_list = [] for (_row, _col), (sr, sc, er, ec) in merged_regions.items(): if (sr, sc, er, ec) in processed: continue processed.add((sr, sc, er, ec)) merged_list.append((sr, sc, er, ec)) for r in range(sr, er + 1): grid_row_base = r * (cell_height + 1) for line in range(1, cell_height + 1): grid_row_idx = grid_row_base + line for c in range(sc, ec + 1): current = grid[grid_row_idx][c] left_border = "|" if c == sc else " " right_border = "|" if c == ec else " " content = current[1:-1] grid[grid_row_idx][c] = left_border + content + right_border if r < er: border_row_idx = grid_row_base + cell_height + 1 if border_row_idx < len(grid): for c in range(sc, ec + 1): left_corner = "+" if c == sc else " " right_corner = "+" if c == ec else " " content = " " * (cell_width - 2) grid[border_row_idx][c] = left_corner + content + right_corner for r in range(sr, er + 1): for border_row in [r * (cell_height + 1), (r + 1) * (cell_height + 1)]: if border_row >= len(grid): continue for c in range(sc, ec + 1): current = grid[border_row][c] left_corner = "+" if c == sc else "-" right_corner = "+" if c == ec else "-" content = "-" * (cell_width - 2) grid[border_row][c] = left_corner + content + right_corner return merged_list def _display_grid_text( texts: list[str], width: int, colors: Optional[list[Optional[Union[str, int]]]], anchors: Optional[list[Optional[str]]], grid_info: dict, ) -> str: """ Display text in a grid layout preserving position information. """ rows = grid_info.get("rows", 1) columns = grid_info.get("columns", 1) merge_cells = grid_info.get("merge", []) text_items = grid_info.get("texts", []) draw_borders = grid_info.get("draw_borders", False) colors_list = colors if colors else [None] * len(texts) anchors_list = anchors if anchors else [None] * len(texts) max_row = ( max([merge[1][0] for merge in merge_cells] + [rows - 1]) if merge_cells else rows - 1 ) actual_rows = max_row + 1 cell_width = width // columns cell_height = 3 if draw_borders: merged_regions = _get_merged_regions(texts, merge_cells, text_items) grid = [ [" " * cell_width for _ in range(columns)] for _ in range(actual_rows * (cell_height + 1) + 1) ] for row in range(actual_rows + 1): for col in range(columns): grid[row * (cell_height + 1)][col] = "+" + "-" * (cell_width - 2) + "+" for row in range(actual_rows): for line in range(1, cell_height + 1): for col in range(columns): grid_row_idx = row * (cell_height + 1) + line grid[grid_row_idx][col] = "|" + " " * (cell_width - 2) + "|" _remove_internal_borders( grid, merged_regions, actual_rows, columns, cell_width, cell_height ) else: grid = [ [" " * cell_width for _ in range(columns)] for _ in range(actual_rows * cell_height) ] merged_regions = {} for i, text in enumerate(texts): position = _get_cell_position(i, merge_cells, text_items) if position is None: continue start_pos, end_pos = position start_row, start_col = start_pos end_row, end_col = end_pos cell_w = (end_col - start_col + 1) * cell_width cell_h = (end_row - start_row + 1) * cell_height anchor = anchors_list[i] if i < len(anchors_list) else "mm" anchor = anchor or "mm" v_align = anchor[0] if len(anchor) > 0 else "m" h_align = anchor[1] if len(anchor) > 1 else "m" if draw_borders: aligned_text = text else: aligned_text = _align_text(text, cell_w, h_align) color = colors_list[i] if i < len(colors_list) else None aligned_text = _apply_color(aligned_text, color) text_row_offset = _get_text_row( v_align, start_row, end_row, cell_height, cell_h ) if draw_borders: text_row = ( start_row * (cell_height + 1) + 1 + (text_row_offset % cell_height) ) else: text_row = text_row_offset if text_row < len(grid): if draw_borders: grid[text_row] = _build_grid_line_with_borders( aligned_text, grid[text_row], start_col, end_col, columns, cell_width, h_align, ) else: grid[text_row] = _build_grid_line( aligned_text, grid[text_row], start_col, end_col, columns, cell_width, ) output_lines = [("".join(row)).rstrip() for row in grid] return "\n".join(output_lines) def _convert_color(rgb: list, brightness: float) -> str: min_distance = 2.0 index = 0 for i in range(len(PALETTE)): tmp = [float(v) * brightness for v in PALETTE[i][0]] distance = _l2_min(tmp, rgb) if distance < min_distance: index = i min_distance = distance return str(PALETTE[index][1])
[docs] def display_as_ascii( img: Image.Image, columns: int = 80, width_ratio: float = 2.2, char: Optional[str] = None, monochrome: bool = False, ) -> str: """ Convert a PIL Image to ASCII art representation. Parameters ---------- img : PIL.Image.Image The image to convert to ASCII art. columns : int, optional Target width in characters for the ASCII output. Default is 80. width_ratio : float, optional Character aspect ratio adjustment (characters are typically taller than they are wide). Default is 2.2. char : str, optional Custom characters to use for rendering, ordered from darkest to brightest. If None, uses default characters " .:−=+*#%@". monochrome : bool, optional If True, output monochrome ASCII without ANSI color codes. If False, use ANSI escape codes for colored output. Default is False. Returns ------- str The ASCII art representation of the image, with newlines separating rows. If colored, includes ANSI escape codes. Examples -------- Basic colored ASCII art: >>> from PIL import Image >>> img = Image.open("photo.jpg") >>> ascii_art = display_as_ascii(img, columns=80) >>> print(ascii_art) Monochrome ASCII art with custom width: >>> ascii_art = display_as_ascii(img, columns=60, monochrome=True) >>> print(ascii_art) Custom character set: >>> ascii_art = display_as_ascii(img, char="█▓▒░ ", columns=100) >>> print(ascii_art) Notes ----- - The image is automatically resized to fit the specified column width - Brightness is calculated from the grayscale version of the image - Color matching (when not monochrome) uses a predefined 16-color palette - The aspect ratio is adjusted using width_ratio to account for terminal character dimensions """ img_w, img_h = img.size scalar = img_w * width_ratio / columns img_w = int(img_w * width_ratio / scalar) img_h = int(img_h / scalar) rgb_img = img.resize((img_w, img_h)) color_palette = img.getpalette() grayscale_img = rgb_img.convert("L") chars = list(char) if char else [" ", ".", ":", "-", "=", "+", "*", "#", "%", "@"] lines = [] previous_color = "" for h in range(img_h): line = "" for w in range(img_w): brightness_pixel = grayscale_img.getpixel((w, h)) if isinstance(brightness_pixel, (int, float)): brightness = brightness_pixel / 255 else: brightness = 0.0 pixel = rgb_img.getpixel((w, h)) if isinstance(pixel, int): pixel = ( # type: ignore[unreachable] (pixel, pixel, 255) if color_palette is None else tuple(color_palette[pixel * 3 : pixel * 3 + 3]) ) ascii_char = chars[int(brightness * (len(chars) - 1))] if monochrome: line += ascii_char else: if isinstance(pixel, tuple) and len(pixel) >= 3: srgb = [(v / 255.0) ** 2.2 for v in pixel[:3]] color_code = _convert_color(srgb, brightness) if color_code == previous_color: line += ascii_char else: line += color_code + ascii_char previous_color = color_code else: line += ascii_char lines.append(line) if monochrome: return "\n".join(lines) else: return "\n".join(lines) + "\033[0m"