Coverage for lintro / utils / formatting.py: 88%
74 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Formatting utilities for core output.
3Includes helpers to read multi-section ASCII art files and normalize
4ASCII blocks to a fixed size (width/height) while preserving shape.
5"""
7import secrets
8from pathlib import Path
11def read_ascii_art(filename: str) -> list[str]:
12 """Read ASCII art from a file.
14 Args:
15 filename: Name of the ASCII art file.
17 Returns:
18 List of lines from one randomly selected ASCII art section.
19 """
20 try:
21 # Get the path to the ASCII art file
22 ascii_art_dir: Path = Path(__file__).parent.parent / "ascii-art"
23 file_path: Path = ascii_art_dir / filename
25 # Read the file and parse sections
26 with file_path.open("r", encoding="utf-8") as f:
27 lines: list[str] = [line.rstrip() for line in f.readlines()]
29 # Find non-empty sections (separated by empty lines)
30 sections: list[list[str]] = []
31 current_section: list[str] = []
33 for line in lines:
34 if line.strip():
35 current_section.append(line)
36 elif current_section:
37 sections.append(current_section)
38 current_section = []
40 # Add the last section if it's not empty
41 if current_section:
42 sections.append(current_section)
44 # Return a random section if there are multiple, otherwise return all lines
45 if sections:
46 # Use ``secrets.choice`` to avoid Bandit B311; cryptographic
47 # strength is not required here, but this silences the warning.
48 return secrets.choice(sections)
49 return lines
50 except (FileNotFoundError, OSError):
51 # Return empty list if file not found or can't be read
52 return []
55def normalize_ascii_block(
56 lines: list[str],
57 *,
58 width: int,
59 height: int,
60 align: str = "center",
61 valign: str = "middle",
62) -> list[str]:
63 """Normalize an ASCII block to a fixed width/height.
65 Lines are trimmed on the right only (rstrip), then padded to ``width``.
66 If a line exceeds ``width``, it is truncated. The whole block is then
67 vertically padded/truncated to ``height``.
69 Args:
70 lines: Original ASCII block lines.
71 width: Target width in characters.
72 height: Target height in lines.
73 align: Horizontal alignment: 'left', 'center', or 'right'.
74 valign: Vertical alignment: 'top', 'middle', or 'bottom'.
76 Returns:
77 list[str]: Normalized lines of length == ``height`` where each line
78 has exactly ``width`` characters.
79 """
80 if width <= 0 or height <= 0:
81 return []
83 def _pad_line(s: str) -> str:
84 s = s.rstrip("\n").rstrip()
85 # Truncate if necessary
86 if len(s) > width:
87 return s[:width]
88 space = width - len(s)
89 if align == "left":
90 return s + (" " * space)
91 if align == "right":
92 return (" " * space) + s
93 # center
94 left = space // 2
95 right = space - left
96 return (" " * left) + s + (" " * right)
98 padded_lines: list[str] = [_pad_line(line) for line in lines]
100 # Vertical pad/truncate
101 if len(padded_lines) >= height:
102 # Truncate based on valign
103 if valign == "top":
104 return padded_lines[:height]
105 if valign == "bottom":
106 return padded_lines[-height:]
107 # middle
108 extra = len(padded_lines) - height
109 top_cut = extra // 2
110 return padded_lines[top_cut : top_cut + height]
112 # Need to add blank lines
113 blank = " " * width
114 missing = height - len(padded_lines)
115 if valign == "top":
116 return padded_lines + [blank] * missing
117 if valign == "bottom":
118 return [blank] * missing + padded_lines
119 top_pad = missing // 2
120 bottom_pad = missing - top_pad
121 return [blank] * top_pad + padded_lines + [blank] * bottom_pad
124def normalize_ascii_file_sections(
125 file_path: Path,
126 *,
127 width: int,
128 height: int,
129 align: str = "center",
130 valign: str = "middle",
131) -> list[list[str]]:
132 """Read a multi-section ASCII file and normalize all sections.
134 Sections are separated by empty lines. Each section is normalized
135 independently and returned as a list of lines.
137 Args:
138 file_path: Path to the ASCII art file.
139 width: Target width.
140 height: Target height.
141 align: Horizontal alignment.
142 valign: Vertical alignment.
144 Returns:
145 list[list[str]]: List of normalized sections.
146 """
147 try:
148 with file_path.open("r", encoding="utf-8") as f:
149 raw_lines: list[str] = [line.rstrip("\n") for line in f]
150 except (FileNotFoundError, OSError):
151 return []
153 sections: list[list[str]] = []
154 current: list[str] = []
155 for line in raw_lines:
156 if line.strip() == "":
157 if current:
158 sections.append(current)
159 current = []
160 else:
161 current.append(line)
162 if current:
163 sections.append(current)
165 normalized: list[list[str]] = [
166 normalize_ascii_block(
167 sec,
168 width=width,
169 height=height,
170 align=align,
171 valign=valign,
172 )
173 for sec in sections
174 ]
175 return normalized