byteforge-figlet 2.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ """ByteForge FIGLet — Python FIGLet rendering library.
2
+
3
+ Quickstart::
4
+
5
+ from byteforge_figlet import FIGFont, FIGLetRenderer, LayoutMode
6
+
7
+ print(FIGLetRenderer.render("Hello!"))
8
+
9
+ Or use the bundled CLI::
10
+
11
+ python -m byteforge_figlet "Hello World"
12
+ figprint "Hello World" --font small
13
+ """
14
+
15
+ from .fig_font import FIGFont
16
+ from .fig_let_renderer import FIGLetRenderer
17
+ from .layout_mode import LayoutMode
18
+ from .smushing_rules import SmushingRules
19
+
20
+ __version__ = "2.0.4"
21
+ __all__ = ["FIGFont", "FIGLetRenderer", "LayoutMode", "SmushingRules"]
@@ -0,0 +1,61 @@
1
+ """CLI entry point — ``python -m byteforge_figlet``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .fig_font import FIGFont
9
+ from .fig_let_renderer import FIGLetRenderer
10
+ from .layout_mode import LayoutMode
11
+
12
+
13
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(
15
+ prog="figprint",
16
+ description="Render text as ASCII art using FIGLet fonts.",
17
+ )
18
+ parser.add_argument("text", nargs="?", help="Text to render (reads stdin if omitted)")
19
+ parser.add_argument(
20
+ "-f", "--font",
21
+ metavar="PATH",
22
+ default=None,
23
+ help="Path to a .flf font file (default: built-in 'small')",
24
+ )
25
+ parser.add_argument(
26
+ "-m", "--mode",
27
+ choices=["full", "kerning", "smush"],
28
+ default="smush",
29
+ help="Layout mode (default: smush)",
30
+ )
31
+ parser.add_argument(
32
+ "--width",
33
+ type=int,
34
+ default=0,
35
+ help="Target output width in characters (0 = unlimited)",
36
+ )
37
+ return parser.parse_args(argv)
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ args = _parse_args(argv)
42
+
43
+ text = args.text
44
+ if not text:
45
+ if sys.stdin.isatty():
46
+ print("Usage: figprint <text> [--font PATH] [--mode full|kerning|smush]", file=sys.stderr)
47
+ return 1
48
+ text = sys.stdin.read().rstrip("\n")
49
+
50
+ font = FIGFont.from_file(args.font) if args.font else None
51
+
52
+ mode_map = {"full": LayoutMode.FullSize, "kerning": LayoutMode.Kerning, "smush": LayoutMode.Smushing}
53
+ mode = mode_map[args.mode]
54
+
55
+ result = FIGLetRenderer.render(text, font=font, mode=mode, line_separator="\n")
56
+ print(result, end="")
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ sys.exit(main())
@@ -0,0 +1,255 @@
1
+ """FIGFont parser — loads and exposes .flf font files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import io
7
+ import zipfile
8
+ from typing import Dict, List, Optional
9
+
10
+ from .smushing_rules import SmushingRules
11
+
12
+
13
+ class _ClassProperty:
14
+ """Descriptor for a lazily-evaluated class-level property.
15
+
16
+ ``@classmethod @property`` was deprecated in Python 3.11 and removed in
17
+ 3.13. This descriptor replicates that behaviour across all supported
18
+ Python versions.
19
+ """
20
+ __slots__ = ("_func",)
21
+
22
+ def __init__(self, func):
23
+ self._func = func
24
+
25
+ def __get__(self, obj, objtype=None):
26
+ return self._func(objtype if objtype is not None else type(obj))
27
+
28
+
29
+ class FIGFont:
30
+ """Represents a FIGfont loaded from a .flf file."""
31
+
32
+ # ------------------------------------------------------------------ #
33
+ # Class-level default font cache
34
+ # ------------------------------------------------------------------ #
35
+ _default: Optional["FIGFont"] = None
36
+
37
+ @_ClassProperty
38
+ def default(cls) -> "FIGFont":
39
+ """Return the built-in 'small' FIGfont (loaded once and cached)."""
40
+ if cls._default is None:
41
+ cls._default = cls._load_default_font()
42
+ if cls._default is None:
43
+ raise RuntimeError("Default FIGfont could not be loaded")
44
+ return cls._default
45
+
46
+ # ------------------------------------------------------------------ #
47
+ # Instance attributes
48
+ # ------------------------------------------------------------------ #
49
+ def __init__(self) -> None:
50
+ self.signature: str = "flf2a"
51
+ self.hard_blank: str = "#"
52
+ self.height: int = 0
53
+ self.baseline: int = 0
54
+ self.max_length: int = 0
55
+ self.old_layout: int = 0
56
+ self.print_direction: int = 0
57
+ self.full_layout: int = 0
58
+ self.characters: Dict[int, List[str]] = {}
59
+ self.smushing_rules: SmushingRules = SmushingRules.NONE
60
+ self.comments: str = ""
61
+
62
+ # ------------------------------------------------------------------ #
63
+ # Factory methods
64
+ # ------------------------------------------------------------------ #
65
+ @classmethod
66
+ def from_file(cls, path: Optional[str]) -> Optional["FIGFont"]:
67
+ """Load a FIGFont from a file path (supports .flf and .zip)."""
68
+ if not path:
69
+ return None
70
+ with open(path, "rb") as fh:
71
+ return cls.from_stream(fh)
72
+
73
+ @classmethod
74
+ def from_stream(cls, stream: Optional[io.RawIOBase]) -> Optional["FIGFont"]:
75
+ """Load a FIGFont from a binary stream (supports .flf inside .zip)."""
76
+ if stream is None:
77
+ return None
78
+ data = stream.read()
79
+ # Detect ZIP archive
80
+ if data[:2] == b"PK":
81
+ try:
82
+ zf_obj = zipfile.ZipFile(io.BytesIO(data))
83
+ except zipfile.BadZipFile:
84
+ return None
85
+ with zf_obj as zf:
86
+ flf_names = [n for n in zf.namelist() if n.lower().endswith(".flf")]
87
+ if not flf_names:
88
+ return None
89
+ with zf.open(flf_names[0]) as entry:
90
+ data = entry.read()
91
+ # Decode using latin-1 (FIGlet standard)
92
+ try:
93
+ text = data.decode("latin-1")
94
+ except UnicodeDecodeError:
95
+ text = data.decode("utf-8", errors="replace")
96
+ return cls.from_lines(text.splitlines())
97
+
98
+ @classmethod
99
+ def from_text(cls, text: Optional[str]) -> Optional["FIGFont"]:
100
+ """Load a FIGFont from a string containing the full .flf content."""
101
+ if not text:
102
+ return None
103
+ return cls.from_lines(text.splitlines())
104
+
105
+ @classmethod
106
+ def from_lines(cls, lines: Optional[List[str]]) -> Optional["FIGFont"]:
107
+ """Load a FIGFont from a list of lines."""
108
+ if not lines:
109
+ return None
110
+
111
+ font = cls()
112
+
113
+ # --- Parse header ---
114
+ header = lines[0].rstrip()
115
+ if not header.startswith("flf2a"):
116
+ raise ValueError("Invalid FIGfont format: header must start with 'flf2a'")
117
+
118
+ try:
119
+ parts = header.split()
120
+ font.signature = parts[0]
121
+ font.hard_blank = parts[0][5] if len(parts[0]) > 5 else "#"
122
+ font.height = int(parts[1])
123
+ font.baseline = int(parts[2])
124
+ font.max_length = int(parts[3])
125
+ font.old_layout = int(parts[4])
126
+ comment_lines = int(parts[5])
127
+ if len(parts) > 6:
128
+ try:
129
+ font.print_direction = int(parts[6])
130
+ except ValueError:
131
+ pass
132
+ if len(parts) > 7:
133
+ try:
134
+ font.full_layout = int(parts[7])
135
+ except ValueError:
136
+ pass
137
+ except (IndexError, ValueError) as exc:
138
+ raise ValueError(f"Error parsing FIGfont header: {exc}") from exc
139
+
140
+ # --- Collect comments ---
141
+ font.comments = "\n".join(lines[1 : 1 + comment_lines])
142
+
143
+ current_line = 1 + comment_lines
144
+
145
+ # --- Load required characters ASCII 32–126 ---
146
+ for code_point in range(32, 127):
147
+ char_lines = cls._read_char_lines(lines, font, current_line)
148
+ font.characters[code_point] = char_lines
149
+ current_line += font.height
150
+
151
+ # --- Load optional extra characters ---
152
+ while current_line < len(lines):
153
+ code_line = lines[current_line].strip()
154
+ if not code_line or not (code_line[0].isdigit() or code_line.startswith("-")):
155
+ break
156
+ # The code point is the first token; optional comment follows
157
+ token = code_line.split(None, 1)[0]
158
+ try:
159
+ code_point = cls._parse_int(token)
160
+ except ValueError:
161
+ break
162
+ current_line += 1
163
+ if current_line + font.height > len(lines):
164
+ break
165
+ char_lines = cls._read_char_lines(lines, font, current_line)
166
+ font.characters[code_point] = char_lines
167
+ current_line += font.height
168
+
169
+ # --- Derive smushing rules ---
170
+ cls._parse_layout_parameters(font)
171
+
172
+ return font
173
+
174
+ # ------------------------------------------------------------------ #
175
+ # Helpers
176
+ # ------------------------------------------------------------------ #
177
+ @staticmethod
178
+ def _strip_endmark(raw: str, endmark: str) -> str:
179
+ """Strip the FIGlet end-of-line endmark character(s) from a raw line.
180
+
181
+ FIGlet fonts mark the end of each glyph row with one endmark character
182
+ (two on the final row). We strip all trailing occurrences so that
183
+ glyphs whose visual shape happens to be the endmark character itself
184
+ (e.g. ``@`` in a font using ``@`` as its endmark) are rendered as empty
185
+ rather than as a stray endmark column.
186
+ """
187
+ stripped = raw.rstrip("\r\n")
188
+ if not endmark:
189
+ return stripped
190
+ return stripped.rstrip(endmark)
191
+
192
+ @staticmethod
193
+ def _read_char_lines(lines: List[str], font: "FIGFont", start: int) -> List[str]:
194
+ # Detect the endmark from the last character of the first raw line.
195
+ first_raw = lines[start].rstrip("\r\n") if start < len(lines) else ""
196
+ endmark = first_raw[-1] if first_raw else "@"
197
+ result: List[str] = []
198
+ for i in range(font.height):
199
+ raw = lines[start + i] if start + i < len(lines) else ""
200
+ stripped = FIGFont._strip_endmark(raw, endmark)
201
+ # Workaround: some fonts use '#' as a secondary endmark even when
202
+ # hard_blank != '#'. Mirrors the same special case in the C# port.
203
+ if stripped.endswith("#") and font.hard_blank != "#":
204
+ stripped = stripped.rstrip("#")
205
+ result.append(stripped)
206
+ return result
207
+
208
+ @staticmethod
209
+ def _parse_int(text: str) -> int:
210
+ text = text.strip()
211
+ if not text:
212
+ return 0
213
+ if text.startswith("-"):
214
+ return -FIGFont._parse_int(text[1:])
215
+ if text.lower().startswith("0x"):
216
+ return int(text[2:], 16)
217
+ if text.lower().startswith("0b"):
218
+ return int(text[2:], 2)
219
+ if len(text) > 1 and text.startswith("0"):
220
+ return int(text, 8)
221
+ return int(text)
222
+
223
+ @staticmethod
224
+ def _parse_layout_parameters(font: "FIGFont") -> None:
225
+ if font.full_layout > 0:
226
+ layout_mask = font.full_layout
227
+ horizontal_smushing_enabled = (layout_mask & 1) == 1
228
+ if not horizontal_smushing_enabled:
229
+ font.smushing_rules = SmushingRules.NONE
230
+ return
231
+ layout_mask = (layout_mask >> 1) & 0x3F
232
+ else:
233
+ layout_mask = font.old_layout
234
+ if layout_mask <= 0:
235
+ font.smushing_rules = SmushingRules.NONE
236
+ return
237
+ layout_mask &= 0x3F
238
+
239
+ font.smushing_rules = SmushingRules(layout_mask)
240
+
241
+ @classmethod
242
+ def _load_default_font(cls) -> Optional["FIGFont"]:
243
+ try:
244
+ pkg = importlib.resources.files("byteforge_figlet") / "fonts" / "small.flf"
245
+ data = pkg.read_bytes()
246
+ return cls.from_stream(io.BytesIO(data))
247
+ except Exception:
248
+ return None
249
+
250
+ # ------------------------------------------------------------------ #
251
+ # Public helpers
252
+ # ------------------------------------------------------------------ #
253
+ def has_smushing_rule(self, rule: SmushingRules) -> bool:
254
+ """Return True if the font defines the given smushing rule."""
255
+ return self.smushing_rules.has_rule(rule)
@@ -0,0 +1,333 @@
1
+ """FIGLetRenderer — renders text using a FIGFont."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import Dict, List, Optional
8
+
9
+ from .fig_font import FIGFont
10
+ from .layout_mode import LayoutMode
11
+ from .smushing_rules import SmushingRules
12
+
13
+ _HIERARCHY_CHARS = r"|/\[]{}()<>"
14
+ _OPPOSITE_PAIRS: Dict[str, str] = {
15
+ "[": "]", "]": "[",
16
+ "{": "}", "}": "{",
17
+ "(": ")", ")": "(",
18
+ "<": ">", ">": "<",
19
+ }
20
+ _ANSI_COLOR_RESET = "\x1b[0m"
21
+
22
+ _RE_LAST_NON_WS = re.compile(r"\S(?=\s*$)")
23
+ _RE_FIRST_NON_WS = re.compile(r"\S")
24
+
25
+
26
+ class FIGLetRenderer:
27
+ """Renders strings to multi-line ASCII art using a FIGFont."""
28
+
29
+ def __init__(
30
+ self,
31
+ font: Optional[FIGFont] = None,
32
+ mode: LayoutMode = LayoutMode.Smushing,
33
+ line_separator: Optional[str] = None,
34
+ use_ansi_colors: bool = False,
35
+ paragraph_mode: bool = True,
36
+ ) -> None:
37
+ self.font: FIGFont = font if font is not None else FIGFont.default
38
+ self.layout_mode: LayoutMode = mode
39
+ self.line_separator: str = line_separator if line_separator is not None else os.linesep
40
+ self.use_ansi_colors: bool = use_ansi_colors
41
+ self.paragraph_mode: bool = paragraph_mode
42
+
43
+ # ------------------------------------------------------------------ #
44
+ # Static convenience method
45
+ # ------------------------------------------------------------------ #
46
+ @staticmethod
47
+ def render(
48
+ text: str,
49
+ font: Optional[FIGFont] = None,
50
+ mode: LayoutMode = LayoutMode.Smushing,
51
+ line_separator: Optional[str] = None,
52
+ use_ansi_colors: bool = False,
53
+ paragraph_mode: bool = True,
54
+ ) -> str:
55
+ """Render *text* and return the ASCII-art string."""
56
+ if not text:
57
+ return ""
58
+ renderer = FIGLetRenderer(font, mode, line_separator, use_ansi_colors, paragraph_mode)
59
+ return renderer.render_text(text)
60
+
61
+ # ------------------------------------------------------------------ #
62
+ # Instance render
63
+ # ------------------------------------------------------------------ #
64
+ def render_text(self, text: str) -> str:
65
+ """Render *text* using the current font and settings."""
66
+ if not text:
67
+ return ""
68
+
69
+ sep = self.line_separator
70
+
71
+ if self.paragraph_mode:
72
+ empty_line = sep * self.font.height
73
+ paragraphs = text.replace("\r\n", "\n").split("\n")
74
+ parts: List[str] = []
75
+ for para in paragraphs:
76
+ if not para.strip():
77
+ parts.append(empty_line)
78
+ else:
79
+ parts.append(self._render_line(para))
80
+ return "".join(parts)
81
+
82
+ clean = text.replace("\r", "").replace("\n", " ")
83
+ return self._render_line(clean)
84
+
85
+ # ------------------------------------------------------------------ #
86
+ # Core line renderer
87
+ # ------------------------------------------------------------------ #
88
+ def _render_line(self, text: str) -> str:
89
+ font = self.font
90
+ mode = self.layout_mode
91
+ sep = self.line_separator
92
+ use_colors = self.use_ansi_colors
93
+
94
+ output_lines: List[List[str]] = [[] for _ in range(font.height)]
95
+
96
+ # First pass: strip ANSI, build plain text + color map
97
+ plain_chars: List[str] = []
98
+ color_dict: Dict[int, str] = {}
99
+ ansi = _ANSIProcessor(preserve_colors=use_colors)
100
+
101
+ for ch in text:
102
+ is_ansi = ansi.process_char(ch)
103
+ if is_ansi:
104
+ continue
105
+ if use_colors and ansi.current_color_sequence:
106
+ color_dict[len(plain_chars)] = ansi.current_color_sequence
107
+ ansi.reset_color_state()
108
+ if ord(ch) in font.characters:
109
+ plain_chars.append(ch)
110
+
111
+ plain_text = "".join(plain_chars)
112
+
113
+ # RTL: reverse text and color map
114
+ if font.print_direction == 1:
115
+ plain_text = plain_text[::-1]
116
+ reversed_colors: Dict[int, str] = {}
117
+ for pos, seq in color_dict.items():
118
+ reversed_colors[len(plain_text) - pos - 1] = seq
119
+ color_dict = reversed_colors
120
+
121
+ # Second pass: composite characters
122
+ char_index = 0
123
+ i = 0
124
+ while i < len(plain_text):
125
+ cp = ord(plain_text[i])
126
+ i += 1
127
+
128
+ char_lines = font.characters[cp]
129
+ color_code = color_dict.get(char_index, "")
130
+ char_index += 1
131
+
132
+ if not output_lines[0]:
133
+ # First character
134
+ for li in range(font.height):
135
+ row: List[str] = []
136
+ if use_colors:
137
+ row.append(color_code)
138
+ row.append(char_lines[li])
139
+ output_lines[li] = row
140
+ else:
141
+ # Calculate minimum overlap across all rows
142
+ overlap = None
143
+ for li in range(font.height):
144
+ existing = "".join(output_lines[li])
145
+ ov = self._calculate_overlap(existing, char_lines[li], mode)
146
+ overlap = ov if overlap is None else min(overlap, ov)
147
+
148
+ assert overlap is not None
149
+
150
+ # Apply smushing
151
+ for li in range(font.height):
152
+ existing = "".join(output_lines[li])
153
+ self._smush(output_lines[li], existing, char_lines[li], overlap, mode, color_code)
154
+
155
+ # Build final string
156
+ rows: List[str] = []
157
+ for li in range(font.height):
158
+ row = "".join(output_lines[li]).replace(font.hard_blank, " ")
159
+ if use_colors:
160
+ row += _ANSI_COLOR_RESET
161
+ rows.append(row)
162
+
163
+ return sep.join(rows) + sep
164
+
165
+ # ------------------------------------------------------------------ #
166
+ # Smushing helpers
167
+ # ------------------------------------------------------------------ #
168
+ def _smush(
169
+ self,
170
+ line_parts: List[str],
171
+ existing: str,
172
+ character: str,
173
+ overlap: int,
174
+ mode: LayoutMode,
175
+ color_code: str,
176
+ ) -> None:
177
+ """Mutate *line_parts* in-place to smush *character* into the existing line."""
178
+ # Rebuild without trailing overlap
179
+ trimmed = existing[: len(existing) - overlap]
180
+ line_end = existing[len(existing) - overlap :]
181
+
182
+ line_parts.clear()
183
+
184
+ if mode == LayoutMode.Kerning:
185
+ line_parts.append(trimmed)
186
+ if self.use_ansi_colors:
187
+ line_parts.append(color_code)
188
+ line_parts.append(character)
189
+ return
190
+
191
+ line_parts.append(trimmed)
192
+ for idx in range(overlap):
193
+ c1 = line_end[idx] if idx < len(line_end) else " "
194
+ c2 = character[idx] if idx < len(character) else " "
195
+ line_parts.append(
196
+ self._smush_characters(c1, c2, self.font.hard_blank, mode, self.font.smushing_rules)
197
+ )
198
+ if self.use_ansi_colors:
199
+ line_parts.append(color_code)
200
+ line_parts.append(character[overlap:])
201
+
202
+ def _calculate_overlap(self, line: str, character: str, mode: LayoutMode) -> int:
203
+ if mode == LayoutMode.FullSize:
204
+ return 0
205
+
206
+ eol = line if len(line) <= len(character) else line[len(line) - len(character):]
207
+ m1 = _RE_LAST_NON_WS.search(eol)
208
+ m2 = _RE_FIRST_NON_WS.search(character)
209
+
210
+ if not m1 or not m2:
211
+ return len(character)
212
+
213
+ can = self._can_smush(m1.group()[0], m2.group()[0], self.font.hard_blank, mode, self.font.smushing_rules)
214
+ overlap = max(len(eol) - m1.start(), m2.start()) + 1 if can else 0
215
+ overlap = min(overlap, len(character))
216
+
217
+ # Special case: opposing slashes reduce overlap by 1
218
+ if can and (
219
+ (m1.group()[0] == "/" and m2.group()[0] == "\\")
220
+ or (m1.group()[0] == "\\" and m2.group()[0] == "/")
221
+ ):
222
+ overlap = max(overlap - 1, 0)
223
+
224
+ return overlap
225
+
226
+ @staticmethod
227
+ def _can_smush(
228
+ c1: str, c2: str, hard_blank: str, mode: LayoutMode, rules: SmushingRules
229
+ ) -> bool:
230
+ if mode == LayoutMode.Kerning:
231
+ return c1 == c2 == " "
232
+ if mode == LayoutMode.FullSize:
233
+ return False
234
+ if c1 == hard_blank or c2 == hard_blank:
235
+ return rules.has_rule(SmushingRules.HardBlank)
236
+ if c1 == " " and c2 == " ":
237
+ return True
238
+ if c1 == " " or c2 == " ":
239
+ return True
240
+ if rules.has_rule(SmushingRules.EqualCharacter) and c1 == c2:
241
+ return True
242
+ if rules.has_rule(SmushingRules.Underscore):
243
+ if (c1 == "_" and c2 in _HIERARCHY_CHARS) or (c2 == "_" and c1 in _HIERARCHY_CHARS):
244
+ return True
245
+ if rules.has_rule(SmushingRules.Hierarchy):
246
+ if c1 in _HIERARCHY_CHARS and c2 in _HIERARCHY_CHARS:
247
+ return True
248
+ if rules.has_rule(SmushingRules.OppositePair):
249
+ if _OPPOSITE_PAIRS.get(c1) == c2:
250
+ return True
251
+ if rules.has_rule(SmushingRules.BigX):
252
+ if (c1 == ">" and c2 == "<") or (c1 == "/" and c2 == "\\") or (c1 == "\\" and c2 == "/"):
253
+ return True
254
+ return False
255
+
256
+ @staticmethod
257
+ def _smush_characters(
258
+ c1: str, c2: str, hard_blank: str, mode: LayoutMode, rules: SmushingRules
259
+ ) -> str:
260
+ if mode == LayoutMode.Kerning:
261
+ return c1
262
+ if c1 == " " and c2 == " ":
263
+ return " "
264
+ if c1 == " ":
265
+ return c2
266
+ if c2 == " ":
267
+ return c1
268
+ if c1 == hard_blank or c2 == hard_blank:
269
+ if rules.has_rule(SmushingRules.HardBlank):
270
+ return hard_blank
271
+ return c1
272
+ if rules.has_rule(SmushingRules.EqualCharacter) and c1 == c2:
273
+ return c1
274
+ if rules.has_rule(SmushingRules.Underscore):
275
+ if c1 == "_" and c2 in _HIERARCHY_CHARS:
276
+ return c2
277
+ if c2 == "_" and c1 in _HIERARCHY_CHARS:
278
+ return c1
279
+ if rules.has_rule(SmushingRules.Hierarchy):
280
+ rank1 = _HIERARCHY_CHARS.index(c1) if c1 in _HIERARCHY_CHARS else -1
281
+ rank2 = _HIERARCHY_CHARS.index(c2) if c2 in _HIERARCHY_CHARS else -1
282
+ if rank1 >= 0 and rank2 >= 0:
283
+ return _HIERARCHY_CHARS[max(rank1, rank2)]
284
+ if rules.has_rule(SmushingRules.OppositePair):
285
+ if _OPPOSITE_PAIRS.get(c1) == c2:
286
+ return "|"
287
+ if rules.has_rule(SmushingRules.BigX):
288
+ if c1 == "/" and c2 == "\\":
289
+ return "|"
290
+ if c1 == "\\" and c2 == "/":
291
+ return "Y"
292
+ if c1 == ">" and c2 == "<":
293
+ return "X"
294
+ if rules.has_rule(SmushingRules.HardBlank):
295
+ if c1 == hard_blank and c2 == hard_blank:
296
+ return hard_blank
297
+ return c1
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Internal ANSI escape sequence processor
302
+ # --------------------------------------------------------------------------- #
303
+ class _ANSIProcessor:
304
+ _NON_COLOR_TERMINATORS = set("ABCDEFGHfJKSTsunhlitrP@XLM")
305
+
306
+ def __init__(self, preserve_colors: bool = False) -> None:
307
+ self._preserve = preserve_colors
308
+ self._in_esc = False
309
+ self._buf: List[str] = []
310
+ self.current_color_sequence: str = ""
311
+
312
+ def process_char(self, ch: str) -> bool:
313
+ if ch == "\x1b":
314
+ self._in_esc = True
315
+ self._buf = [ch]
316
+ return True
317
+ if self._in_esc:
318
+ self._buf.append(ch)
319
+ if len(self._buf) == 2 and ch != "[":
320
+ self._in_esc = False
321
+ return True
322
+ if len(self._buf) >= 3 and (("\x40" <= ch <= "\x7e") or ch == "m"):
323
+ self._in_esc = False
324
+ seq = "".join(self._buf)
325
+ self._buf = []
326
+ if ch == "m" and self._preserve:
327
+ self.current_color_sequence += seq
328
+ return True
329
+ return True
330
+ return False
331
+
332
+ def reset_color_state(self) -> None:
333
+ self.current_color_sequence = ""
File without changes