132 lines
3.0 KiB
Python
132 lines
3.0 KiB
Python
from dataclasses import dataclass, field
|
|||
|
|
from enum import Enum
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
|
||
|
|
class Direction(Enum):
|
||
|
|
ACROSS = "across"
|
||
|
|
DOWN = "down"
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class WordClue:
|
||
|
|
"""A word-clue pair parsed from the input CSV."""
|
||
|
|
|
||
|
|
word: str
|
||
|
|
clue: str
|
||
|
|
difficulty: Optional[str] = None
|
||
|
|
category: Optional[str] = None
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Cell:
|
||
|
|
"""A single cell in the crossword grid."""
|
||
|
|
|
||
|
|
letter: Optional[str] = None
|
||
|
|
number: Optional[int] = None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_empty(self) -> bool:
|
||
|
|
return self.letter is None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_numbered(self) -> bool:
|
||
|
|
return self.number is not None
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class PlacedWord:
|
||
|
|
"""A word placed on the crossword grid with its position and direction."""
|
||
|
|
|
||
|
|
word: str
|
||
|
|
clue: str
|
||
|
|
row: int
|
||
|
|
col: int
|
||
|
|
direction: Direction
|
||
|
|
number: Optional[int] = None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def length(self) -> int:
|
||
|
|
return len(self.word)
|
||
|
|
|
||
|
|
def cells(self) -> list[tuple[int, int]]:
|
||
|
|
"""Return the (row, col) positions this word occupies."""
|
||
|
|
if self.direction == Direction.ACROSS:
|
||
|
|
return [(self.row, self.col + i) for i in range(self.length)]
|
||
|
|
return [(self.row + i, self.col) for i in range(self.length)]
|
||
|
|
|
||
|
|
def end_row(self) -> int:
|
||
|
|
if self.direction == Direction.DOWN:
|
||
|
|
return self.row + self.length - 1
|
||
|
|
return self.row
|
||
|
|
|
||
|
|
def end_col(self) -> int:
|
||
|
|
if self.direction == Direction.ACROSS:
|
||
|
|
return self.col + self.length - 1
|
||
|
|
return self.col
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class NumberedClue:
|
||
|
|
"""A clue with its assigned number, ready for display."""
|
||
|
|
|
||
|
|
number: int
|
||
|
|
clue: str
|
||
|
|
word: str
|
||
|
|
direction: Direction
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Crossword:
|
||
|
|
"""A complete crossword puzzle: grid, placed words, and numbered clues."""
|
||
|
|
|
||
|
|
grid: list[list[Cell]]
|
||
|
|
placed_words: list[PlacedWord]
|
||
|
|
across_clues: list[NumberedClue] = field(default_factory=list)
|
||
|
|
down_clues: list[NumberedClue] = field(default_factory=list)
|
||
|
|
width: int = 0
|
||
|
|
height: int = 0
|
||
|
|
|
||
|
|
@property
|
||
|
|
def word_count(self) -> int:
|
||
|
|
return len(self.placed_words)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def density(self) -> float:
|
||
|
|
"""Fraction of grid cells that contain letters."""
|
||
|
|
total = self.width * self.height
|
||
|
|
if total == 0:
|
||
|
|
return 0.0
|
||
|
|
filled = sum(1 for row in self.grid for cell in row if not cell.is_empty)
|
||
|
|
return filled / total
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class TrimSize:
|
||
|
|
"""KDP trim size specification in inches."""
|
||
|
|
|
||
|
|
width: float
|
||
|
|
height: float
|
||
|
|
label: str
|
||
|
|
|
||
|
|
def __str__(self) -> str:
|
||
|
|
return self.label
|
||
|
|
|
||
|
|
|
||
|
|
TRIM_SIZES: dict[str, TrimSize] = {
|
||
|
|
"8.5x11": TrimSize(8.5, 11.0, "8.5\" x 11\""),
|
||
|
|
"6x9": TrimSize(6.0, 9.0, "6\" x 9\""),
|
||
|
|
"8x10": TrimSize(8.0, 10.0, "8\" x 10\""),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class PuzzleBook:
|
||
|
|
"""A collection of crossword puzzles forming a book."""
|
||
|
|
|
||
|
|
title: str
|
||
|
|
author: str
|
||
|
|
puzzles: list[Crossword] = field(default_factory=list)
|
||
|
|
trim_size: TrimSize = field(default_factory=lambda: TRIM_SIZES["8.5x11"])
|
||
|
|
solutions_at_end: bool = False
|