hachure 0.3.0__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.
hachure/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Rendu d'art ASCII dans le terminal."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.3.0"
hachure/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
hachure/charsets.py ADDED
@@ -0,0 +1,194 @@
1
+ """Rampes luminosité-vers-caractère partagées, et les outils pour les calibrer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ CHARSETS = {
9
+ "classic": " .:-=+*#%@",
10
+ "detailed": " .`^\\,:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
11
+ "letters": ".,:;irsXA253hMHGS#9B&@",
12
+ # Mesurée sur Cascadia Mono avec `hachure calibrate` : pas réguliers en
13
+ # couverture d'encre réelle, biaisée vers les glyphes dont l'encre est
14
+ # répartie sur toute la cellule, et sans les quatre glyphes de ligne
15
+ # réservés à l'orientation des contours.
16
+ "smooth": " :;!?c7S58B@",
17
+ }
18
+
19
+ DEFAULT_CHARSET = "classic"
20
+
21
+ # Glyphes candidats à la calibration : l'ASCII imprimable moins l'espace, qui
22
+ # occupe toujours l'extrémité vide d'une rampe ; moins les caractères assez
23
+ # éloignés de la ligne de base pour se lire comme du bruit plutôt que comme un
24
+ # ton ; et moins les quatre glyphes de ligne, réservés à l'orientation des
25
+ # contours et qui doivent rester sans ambiguïté.
26
+ _CALIBRATION_EXCLUDED = "`'\",._^~-|/\\"
27
+
28
+ _CALIBRATION_TILES = 3
29
+
30
+
31
+ def get_charset(name: str = DEFAULT_CHARSET, *, invert: bool = False) -> str:
32
+ """Renvoie une rampe de caractères nommée, du sombre vers le clair."""
33
+ try:
34
+ ramp = CHARSETS[name]
35
+ except KeyError as exc:
36
+ choices = ", ".join(sorted(CHARSETS))
37
+ raise ValueError(f"Jeu de caractères '{name}' inconnu. Choix possibles : {choices}.") from exc
38
+ return ramp[::-1] if invert else ramp
39
+
40
+
41
+ def brightness_to_index(brightness: float, ramp_length: int) -> int:
42
+ """Convertit une luminosité de 0..255 en un index de rampe toujours valide."""
43
+ if ramp_length < 1:
44
+ raise ValueError("Une rampe de caractères ne peut pas être vide.")
45
+ value = max(0.0, min(255.0, float(brightness)))
46
+ return int(value * (ramp_length - 1) / 255.0)
47
+
48
+
49
+ def brightness_to_char(brightness: float, ramp: str) -> str:
50
+ return ramp[brightness_to_index(brightness, len(ramp))]
51
+
52
+
53
+ def candidate_characters() -> str:
54
+ """L'ASCII imprimable digne d'être mesuré, dans l'ordre des points de code."""
55
+ return "".join(
56
+ character
57
+ for code in range(0x21, 0x7F)
58
+ if (character := chr(code)) not in _CALIBRATION_EXCLUDED
59
+ )
60
+
61
+
62
+ def measure_glyphs(
63
+ font_path: Path, *, size: int = 32, characters: str | None = None
64
+ ) -> dict[str, tuple[float, float]]:
65
+ """Mesure la couverture d'encre de chaque glyphe et la régularité de sa répartition.
66
+
67
+ Renvoie ``{caractère: (couverture, uniformité)}``, les deux dans ``0..1``.
68
+ La couverture est le critère de tri d'une rampe ; l'uniformité est ce qui
69
+ distingue un glyphe lu comme un aplat de ton d'un glyphe lu comme une marque.
70
+ """
71
+ from PIL import Image, ImageDraw, ImageFont
72
+
73
+ font = ImageFont.truetype(str(font_path), size)
74
+ ascent, descent = font.getmetrics()
75
+ cell_height = max(1, ascent + descent)
76
+ cell_width = max(1, round(font.getlength("M")))
77
+
78
+ results: dict[str, tuple[float, float]] = {}
79
+ for character in characters if characters is not None else candidate_characters():
80
+ image = Image.new("L", (cell_width, cell_height), 0)
81
+ ImageDraw.Draw(image).text((0, 0), character, font=font, fill=255)
82
+ pixels = image.load()
83
+
84
+ total = 0.0
85
+ tiles: list[float] = []
86
+ tile_height = max(1, cell_height // _CALIBRATION_TILES)
87
+ tile_width = max(1, cell_width // _CALIBRATION_TILES)
88
+ for tile_y in range(_CALIBRATION_TILES):
89
+ for tile_x in range(_CALIBRATION_TILES):
90
+ ink = 0.0
91
+ count = 0
92
+ for y in range(tile_y * tile_height, min((tile_y + 1) * tile_height, cell_height)):
93
+ for x in range(tile_x * tile_width, min((tile_x + 1) * tile_width, cell_width)):
94
+ ink += pixels[x, y]
95
+ count += 1
96
+ if count:
97
+ tiles.append(ink / (count * 255.0))
98
+ total += ink
99
+
100
+ coverage = total / (cell_width * cell_height * 255.0)
101
+ if tiles:
102
+ mean = sum(tiles) / len(tiles)
103
+ variance = sum((value - mean) ** 2 for value in tiles) / len(tiles)
104
+ spread = variance**0.5
105
+ uniformity = 1.0 - min(1.0, spread / mean) if mean > 0 else 0.0
106
+ else:
107
+ uniformity = 0.0
108
+ results[character] = (coverage, uniformity)
109
+ return results
110
+
111
+
112
+ def build_ramp(
113
+ measurements: dict[str, tuple[float, float]],
114
+ *,
115
+ length: int = 12,
116
+ uniformity_weight: float = 1.0,
117
+ ) -> str:
118
+ """Choisit une rampe dont les pas sont régulièrement espacés en couverture d'encre réelle.
119
+
120
+ Chaque pas vise une fraction régulière de la plage de couverture mesurée ;
121
+ parmi les glyphes proches de cette cible, celui dont l'encre est la mieux
122
+ répartie l'emporte.
123
+ """
124
+ if length < 2:
125
+ raise ValueError("Une rampe demande au moins deux caractères.")
126
+ if not measurements:
127
+ raise ValueError("Aucune mesure de glyphe n'a été fournie.")
128
+
129
+ ordered = sorted(measurements.items(), key=lambda item: item[1][0])
130
+ lightest = ordered[0][1][0]
131
+ heaviest = ordered[-1][1][0]
132
+ if heaviest <= lightest:
133
+ raise ValueError("La couverture des glyphes n'a pas varié ; la police est-elle à chasse fixe ?")
134
+
135
+ span = heaviest - lightest
136
+ # Les glyphes à cette distance de la cible sont considérés comme
137
+ # interchangeables : le départage se fait alors sur la régularité de
138
+ # répartition de leur encre.
139
+ window = span / (2.0 * (length - 1))
140
+
141
+ ramp = [" "]
142
+ used = {" "}
143
+ previous_coverage = 0.0
144
+
145
+ for step in range(1, length):
146
+ target = lightest + span * (step - 1) / (length - 2) if length > 2 else heaviest
147
+ available = [
148
+ (character, coverage, uniformity)
149
+ for character, (coverage, uniformity) in ordered
150
+ if character not in used and coverage >= previous_coverage
151
+ ]
152
+ if not available:
153
+ break
154
+
155
+ near = [entry for entry in available if abs(entry[1] - target) <= window]
156
+ if near:
157
+ character, coverage, _ = max(
158
+ near,
159
+ key=lambda entry: entry[2] * uniformity_weight
160
+ - abs(entry[1] - target) / span,
161
+ )
162
+ else:
163
+ character, coverage, _ = min(available, key=lambda entry: abs(entry[1] - target))
164
+
165
+ ramp.append(character)
166
+ used.add(character)
167
+ previous_coverage = coverage
168
+
169
+ return "".join(ramp)
170
+
171
+
172
+ def calibrate(font_path: Path, *, size: int = 32, length: int = 12) -> str:
173
+ """Mesure une police et renvoie une rampe calibrée pour elle."""
174
+ return build_ramp(measure_glyphs(font_path, size=size), length=length)
175
+
176
+
177
+ def find_monospace_font(candidates: Iterable[Path] | None = None) -> Path | None:
178
+ """Localise une police à chasse fixe sur laquelle se calibrer."""
179
+ search: tuple[Path, ...]
180
+ if candidates is not None:
181
+ search = tuple(candidates)
182
+ else:
183
+ search = (
184
+ Path("C:/Windows/Fonts/CascadiaMono.ttf"),
185
+ Path("C:/Windows/Fonts/consola.ttf"),
186
+ Path("C:/Windows/Fonts/lucon.ttf"),
187
+ Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"),
188
+ Path("/usr/share/fonts/TTF/DejaVuSansMono.ttf"),
189
+ Path("/System/Library/Fonts/Menlo.ttc"),
190
+ )
191
+ for candidate in search:
192
+ if candidate.is_file():
193
+ return candidate
194
+ return None