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.
- byteforge_figlet/__init__.py +21 -0
- byteforge_figlet/__main__.py +61 -0
- byteforge_figlet/fig_font.py +255 -0
- byteforge_figlet/fig_let_renderer.py +333 -0
- byteforge_figlet/fonts/.gitkeep +0 -0
- byteforge_figlet/fonts/small.flf +1062 -0
- byteforge_figlet/layout_mode.py +18 -0
- byteforge_figlet/smushing_rules.py +32 -0
- byteforge_figlet-2.0.4.dist-info/METADATA +229 -0
- byteforge_figlet-2.0.4.dist-info/RECORD +13 -0
- byteforge_figlet-2.0.4.dist-info/WHEEL +4 -0
- byteforge_figlet-2.0.4.dist-info/entry_points.txt +2 -0
- byteforge_figlet-2.0.4.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|