Crossword Puzzle Grid — Bokeh

A crossword puzzle grid visualization with white entry cells for letters, black blocking cells, and numbered starting positions for across and down words. The symmetric black cell pattern follows traditional newspaper-style crossword conventions, creating a clean and recognizable puzzle layout suitable for printing or digital display.

Crossword Puzzle Grid rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
crossword-basic: Crossword Puzzle Grid
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 84/100 | Updated: 2026-05-20
"""

import sys
from pathlib import Path


_script_dir = str(Path(__file__).parent.absolute())
sys.path = [p for p in sys.path if p != _script_dir and p != ""]

import os
import time

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool, LabelSet
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
# Warmer off-white for entry cells — avoids harsh pure-white against the warm page background
CELL_BG = "#FFFDF6" if THEME == "light" else "#FFFFFF"

# Data — 15x15 crossword grid with 180-degree rotational symmetry
np.random.seed(42)
grid_size = 15
grid = np.zeros((grid_size, grid_size), dtype=int)

black_positions = [
    (0, 4),
    (0, 10),
    (1, 4),
    (1, 10),
    (2, 7),
    (3, 0),
    (3, 6),
    (3, 11),
    (3, 12),
    (4, 5),
    (4, 9),
    (5, 3),
    (5, 8),
    (5, 13),
    (6, 2),
    (6, 7),
    (6, 12),
    (7, 0),
    (7, 6),
    (7, 8),
    (7, 14),
]

for r, c in black_positions:
    grid[r, c] = 1
    grid[grid_size - 1 - r, grid_size - 1 - c] = 1

# Clue numbering: cells that start an across or down word
numbers = {}
clue_num = 1
for r in range(grid_size):
    for c in range(grid_size):
        if grid[r, c] == 1:
            continue
        starts_across = (c == 0 or grid[r, c - 1] == 1) and (c < grid_size - 1 and grid[r, c + 1] == 0)
        starts_down = (r == 0 or grid[r - 1, c] == 1) and (r < grid_size - 1 and grid[r + 1, c] == 0)
        if starts_across or starts_down:
            numbers[(r, c)] = clue_num
            clue_num += 1

# Separate cell data for rendering, hover, and clue number labels
white_x, white_y = [], []
black_x, black_y = [], []
numbered_x, numbered_y, numbered_label = [], [], []
label_x, label_y, label_text = [], [], []

for r in range(grid_size):
    for c in range(grid_size):
        y = grid_size - 1 - r  # flip so row 0 is at top
        if grid[r, c] == 0:
            white_x.append(c)
            white_y.append(y)
            if (r, c) in numbers:
                numbered_x.append(c)
                numbered_y.append(y)
                numbered_label.append(f"Clue {numbers[(r, c)]}")
                label_x.append(c - 0.43)
                label_y.append(y + 0.18)
                label_text.append(str(numbers[(r, c)]))
        else:
            black_x.append(c)
            black_y.append(y)

# Plot — square canvas for 1:1 cell aspect ratio
p = figure(
    width=2400,
    height=2400,
    title="crossword-basic · python · bokeh · anyplot.ai",
    x_range=(-0.6, grid_size - 0.4),
    y_range=(-0.6, grid_size - 0.4),
    toolbar_location=None,
    min_border_bottom=80,
    min_border_left=80,
    min_border_top=110,
    min_border_right=50,
)

p.xaxis.visible = False
p.yaxis.visible = False
p.xgrid.visible = False
p.ygrid.visible = False

p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = None

p.title.text_font_size = "50pt"
p.title.align = "center"
p.title.text_color = INK

# White entry cells — warm off-white fill to complement the page surface
white_source = ColumnDataSource(data={"x": white_x, "y": white_y})
p.rect(
    x="x", y="y", source=white_source, width=0.96, height=0.96, fill_color=CELL_BG, line_color=INK_SOFT, line_width=1.5
)

# Transparent overlay on numbered cells — hover targets without visual double-drawing
numbered_source = ColumnDataSource(data={"x": numbered_x, "y": numbered_y, "label": numbered_label})
numbered_renderer = p.rect(x="x", y="y", source=numbered_source, width=0.96, height=0.96, fill_alpha=0, line_alpha=0)
hover = HoverTool(renderers=[numbered_renderer], tooltips=[("", "@label")])
p.add_tools(hover)

# Black blocking cells
black_source = ColumnDataSource(data={"x": black_x, "y": black_y})
p.rect(
    x="x",
    y="y",
    source=black_source,
    width=0.96,
    height=0.96,
    fill_color="#111110",
    line_color="#111110",
    line_width=1.5,
)

# Outer perimeter border — thicker frame gives the puzzle a polished, bounded appearance
p.rect(x=7, y=7, width=15.0, height=15.0, fill_alpha=0, line_color=INK, line_width=5)

# Clue numbers via LabelSet — idiomatic Bokeh, batched from ColumnDataSource
label_source = ColumnDataSource(data={"lx": label_x, "ly": label_y, "lt": label_text})
labels = LabelSet(
    x="lx", y="ly", text="lt", source=label_source, text_font_size="20pt", text_font_style="bold", text_color=INK_SOFT
)
p.add_layout(labels)

# Save HTML (interactive artifact)
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot via headless Chrome (export_png not used — snap driver conflict)
W, H = 2400, 2400
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Part of Crossword Puzzle Grid on anyplot.ai.

Other implementations