appflows 1.0.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.
- appflows/__init__.py +94 -0
- appflows/cli.py +237 -0
- appflows/config.py +533 -0
- appflows/formats.py +574 -0
- appflows/generator.py +934 -0
- appflows/layout.py +845 -0
- appflows/logtext.py +24 -0
- appflows/models.py +307 -0
- appflows/py.typed +0 -0
- appflows/rows.py +207 -0
- appflows/sides.py +189 -0
- appflows/svg.py +494 -0
- appflows/ui/__init__.py +10 -0
- appflows/ui/app.py +346 -0
- appflows/ui/server.py +101 -0
- appflows/ui/service.py +226 -0
- appflows/ui/static/editor.js +646 -0
- appflows/ui/static/index.html +82 -0
- appflows/ui/static/style.css +333 -0
- appflows-1.0.0.dist-info/METADATA +211 -0
- appflows-1.0.0.dist-info/RECORD +24 -0
- appflows-1.0.0.dist-info/WHEEL +4 -0
- appflows-1.0.0.dist-info/entry_points.txt +3 -0
- appflows-1.0.0.dist-info/licenses/LICENSE +21 -0
appflows/config.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Configuration du rendu graphique.
|
|
2
|
+
|
|
3
|
+
`GraphConfig` regroupe tous les paramètres de mise en page et de couleur du SVG
|
|
4
|
+
produit par :class:`appflows.generator.Generator`, rangés par sujet : page,
|
|
5
|
+
application, centre, flux, tag, bus, connecteur. Chaque sujet est une
|
|
6
|
+
dataclass, et l'arborescence est **celle du fichier** de configuration :
|
|
7
|
+
``cfg.flow.label.halo.width`` se lit ``flow: {label: {halo: {width: 3}}}``.
|
|
8
|
+
|
|
9
|
+
La configuration est sérialisable via :meth:`GraphConfig.to_dict` /
|
|
10
|
+
:meth:`GraphConfig.from_dict`. Les valeurs lues depuis un fichier sont
|
|
11
|
+
contrôlées avant d'être retenues : une définition peut provenir d'une source
|
|
12
|
+
non maîtrisée, et une couleur non validée finirait telle quelle dans le SVG.
|
|
13
|
+
Le contrôle est porté par les champs eux-mêmes (``field(metadata=...)``), ce
|
|
14
|
+
qui évite de tenir à part la liste des champs de chaque sorte.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
19
|
+
from typing import Any, NamedTuple, TypeGuard, TypeVar, get_args, get_origin
|
|
20
|
+
|
|
21
|
+
from loguru import logger
|
|
22
|
+
|
|
23
|
+
from appflows.logtext import excerpt
|
|
24
|
+
|
|
25
|
+
#: Code de l'application centrale par défaut.
|
|
26
|
+
MAIN_APP_CODE = "m"
|
|
27
|
+
|
|
28
|
+
#: Couleur admise : notation hexadécimale (``#RGB``, ``#RRGGBB``, ``#RRGGBBAA``)
|
|
29
|
+
#: ou mot-clé CSS. Tout le reste est refusé : la valeur est interpolée dans le
|
|
30
|
+
#: SVG, y compris dans un bloc ``<style>`` qui ne peut pas être échappé.
|
|
31
|
+
COLOR_PATTERN = re.compile(r"#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|[a-zA-Z]+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: Variations appliquées aux couleurs de l'application pour habiller son tag,
|
|
35
|
+
#: faute de couleurs déclarées pour ce tag : bordure assombrie, fond éclairci.
|
|
36
|
+
#: Le texte, lui, reprend celui de l'application sans retouche.
|
|
37
|
+
TAG_BORDER_DARKEN = 0.30
|
|
38
|
+
TAG_BACKGROUND_LIGHTEN = 0.45
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Colors(NamedTuple):
|
|
42
|
+
"""Habillage d'une boîte ou d'une pastille : texte, bordure, fond.
|
|
43
|
+
|
|
44
|
+
Un tuple nommé plutôt qu'une dataclass : le triplet se dépaquette
|
|
45
|
+
(``_, border, fill = colors``) et s'indexe comme le faisait le tuple
|
|
46
|
+
anonyme, tout en donnant un nom à chaque couleur.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
text: str
|
|
50
|
+
stroke: str
|
|
51
|
+
fill: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ----------------------------------------------------------------------
|
|
55
|
+
# Contrôle des feuilles
|
|
56
|
+
# ----------------------------------------------------------------------
|
|
57
|
+
#
|
|
58
|
+
# Chaque feuille porte sa règle dans ``metadata`` :
|
|
59
|
+
# - ``positive`` : entier ou réel strictement positif — une police ou une
|
|
60
|
+
# largeur plancher nulle ferait disparaître ce qu'elle mesure ;
|
|
61
|
+
# - ``signed`` : entier de signe libre — une retouche de centrage doit
|
|
62
|
+
# pouvoir remonter comme descendre ;
|
|
63
|
+
# - ``color`` : chaîne de couleur, contrôlée par ``COLOR_PATTERN``.
|
|
64
|
+
# Sans metadata, un entier est positif ou nul : dimension ou blanc.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _positive(default: int | float) -> Any:
|
|
68
|
+
return field(default=default, metadata={"positive": True})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _signed(default: int) -> Any:
|
|
72
|
+
return field(default=default, metadata={"signed": True})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _color(default: str) -> Any:
|
|
76
|
+
return field(default=default, metadata={"color": True})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ----------------------------------------------------------------------
|
|
80
|
+
# Sous-objets partagés
|
|
81
|
+
# ----------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Size:
|
|
86
|
+
"""Gabarit d'un rectangle d'application.
|
|
87
|
+
|
|
88
|
+
``width`` est une largeur **plancher** : une colonne dont les libellés
|
|
89
|
+
l'exigent s'élargit (voir ``layout.Layout._lane_width``). ``height`` est
|
|
90
|
+
celle d'un rectangle à un seul flux.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
width: int = _positive(100)
|
|
94
|
+
height: int = _positive(48)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Margin:
|
|
99
|
+
"""Marges de la page, avant le premier rectangle."""
|
|
100
|
+
|
|
101
|
+
top: int = 28
|
|
102
|
+
left: int = 28
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Padding:
|
|
107
|
+
"""Blanc laissé autour d'un texte dans sa boîte."""
|
|
108
|
+
|
|
109
|
+
x: int = 12
|
|
110
|
+
y: int = 8
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Halo:
|
|
115
|
+
"""Trait tracé derrière un texte pour qu'il reste lisible sur un fond chargé."""
|
|
116
|
+
|
|
117
|
+
#: ``0`` désactive le halo.
|
|
118
|
+
width: int = 3
|
|
119
|
+
#: La couleur du fond sur lequel le schéma est posé.
|
|
120
|
+
color: str = _color("#ffffff")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Line:
|
|
125
|
+
"""Un trait : épaisseur et couleur."""
|
|
126
|
+
|
|
127
|
+
width: int = 2
|
|
128
|
+
color: str = _color("#9e9e9e")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Port:
|
|
133
|
+
"""Port écrit à côté de la pastille d'un connecteur, à l'intérieur de la boîte."""
|
|
134
|
+
|
|
135
|
+
font_size: int = _positive(10)
|
|
136
|
+
gap: int = 4
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class FlowLabel:
|
|
141
|
+
"""Libellé dessiné au-dessus d'une flèche de flux."""
|
|
142
|
+
|
|
143
|
+
font_size: int = _positive(12)
|
|
144
|
+
#: Couleur du libellé, distincte de celle du trait.
|
|
145
|
+
color: str = _color("#000000")
|
|
146
|
+
#: Hauteur du libellé au-dessus de son trait.
|
|
147
|
+
offset_y: int = 8
|
|
148
|
+
#: Halo tracé derrière le libellé, pour qu'il reste lisible là où il
|
|
149
|
+
#: croise un trait.
|
|
150
|
+
halo: Halo = field(default_factory=Halo)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ----------------------------------------------------------------------
|
|
154
|
+
# Sections
|
|
155
|
+
# ----------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class PageConfig:
|
|
160
|
+
"""La page : marges et espacement entre colonnes et lignes."""
|
|
161
|
+
|
|
162
|
+
margin: Margin = field(default_factory=Margin)
|
|
163
|
+
#: Blanc horizontal entre deux colonnes.
|
|
164
|
+
hspace: int = 28
|
|
165
|
+
#: Blanc vertical entre deux boîtes d'une même colonne.
|
|
166
|
+
vspace: int = 28
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class AppConfig:
|
|
171
|
+
"""Les rectangles d'application, centre compris sauf mention contraire."""
|
|
172
|
+
|
|
173
|
+
size: Size = field(default_factory=Size)
|
|
174
|
+
corner: int = 8
|
|
175
|
+
#: Épaisseur de la bordure des rectangles.
|
|
176
|
+
stroke_width: int = 2
|
|
177
|
+
#: ``x`` : blanc de chaque côté du libellé. ``y`` : blanc au-dessus du
|
|
178
|
+
#: libellé et sous le dernier flux, quand une boîte porte des connecteurs
|
|
179
|
+
#: et range donc son nom au-dessus d'eux.
|
|
180
|
+
padding: Padding = field(default_factory=lambda: Padding(x=12, y=8))
|
|
181
|
+
font_size: int = _positive(14)
|
|
182
|
+
#: Retouche fine du centrage vertical d'un libellé. Nulle par défaut :
|
|
183
|
+
#: `dominant-baseline` centre déjà ; à n'ajuster que si une police
|
|
184
|
+
#: substituée tombe mal.
|
|
185
|
+
text_vertical_offset: int = _signed(0)
|
|
186
|
+
colors: Colors = field(default_factory=lambda: Colors("#0050ef", "#6c8ebf", "#dae8fc"))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class MainConfig:
|
|
191
|
+
"""Ce qui distingue l'application centrale des satellites."""
|
|
192
|
+
|
|
193
|
+
font_size: int = _positive(16)
|
|
194
|
+
#: Largeur de l'application centrale, en multiple de celle des satellites.
|
|
195
|
+
#: Son propre libellé reste un plancher : elle ne rétrécit jamais dessous.
|
|
196
|
+
width_ratio: float = _positive(1.2)
|
|
197
|
+
colors: Colors = field(default_factory=lambda: Colors("#000000", "#9673a6", "#e1d5e7"))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class FlowConfig:
|
|
202
|
+
"""Les flèches de flux et leur libellé."""
|
|
203
|
+
|
|
204
|
+
#: Largeur de la bande réservée aux flèches entre deux colonnes.
|
|
205
|
+
width: int = _positive(160)
|
|
206
|
+
#: Blanc entre le bord d'une boîte et le premier flux.
|
|
207
|
+
margin: int = 18
|
|
208
|
+
#: Blanc entre la pointe d'une flèche et ce qu'elle désigne — bord de
|
|
209
|
+
#: rectangle, de boîte de bus ou de pastille de connecteur. Nul par
|
|
210
|
+
#: défaut : la pointe touche sa cible, ce qui donne à lire le raccordement.
|
|
211
|
+
gap: int = 0
|
|
212
|
+
#: Épaisseur du trait d'une flèche.
|
|
213
|
+
stroke_width: int = 2
|
|
214
|
+
#: Longueur de la pointe de flèche.
|
|
215
|
+
arrow: int = 12
|
|
216
|
+
color: str = _color("#004C99")
|
|
217
|
+
label: FlowLabel = field(default_factory=FlowLabel)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class TagConfig:
|
|
222
|
+
"""La pastille de tag, sous le nom d'une application."""
|
|
223
|
+
|
|
224
|
+
font_size: int = _positive(11)
|
|
225
|
+
padding: Padding = field(default_factory=lambda: Padding(x=8, y=3))
|
|
226
|
+
corner: int = 6
|
|
227
|
+
stroke_width: int = 1
|
|
228
|
+
#: Écart entre le libellé de l'application et la pastille de son tag.
|
|
229
|
+
gap: int = 6
|
|
230
|
+
#: Habillage par nom de tag. Un tag absent de la table reprend les
|
|
231
|
+
#: couleurs de son application, nuancées par :func:`derive_tag_colors`.
|
|
232
|
+
colors: dict[str, Colors] = field(default_factory=dict)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class BusConfig:
|
|
237
|
+
"""La boîte d'un bus : angles vifs par défaut, bordure gris moyen et fond
|
|
238
|
+
gris clair, pour qu'elle se lise comme une infrastructure et non comme une
|
|
239
|
+
application."""
|
|
240
|
+
|
|
241
|
+
corner: int = 0
|
|
242
|
+
stroke_width: int = 2
|
|
243
|
+
font_size: int = _positive(13)
|
|
244
|
+
#: ``y`` : blanc au-dessus et au-dessous des tronçons, dans la boîte. Le
|
|
245
|
+
#: blanc du haut porte aussi le nom du bus.
|
|
246
|
+
padding: Padding = field(default_factory=lambda: Padding(x=12, y=8))
|
|
247
|
+
colors: Colors = field(default_factory=lambda: Colors("#333333", "#9e9e9e", "#f0f0f0"))
|
|
248
|
+
#: Trait qui matérialise, dans la boîte, la traversée d'un flux.
|
|
249
|
+
line: Line = field(default_factory=Line)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class ConnectorConfig:
|
|
254
|
+
"""La pastille d'un connecteur, posée à cheval sur le bord de sa boîte."""
|
|
255
|
+
|
|
256
|
+
height: int = _positive(18)
|
|
257
|
+
#: Largeur plancher, et largeur exacte d'un connecteur neutre.
|
|
258
|
+
min_width: int = _positive(14)
|
|
259
|
+
corner: int = 3
|
|
260
|
+
padding_x: int = 6
|
|
261
|
+
font_size: int = _positive(10)
|
|
262
|
+
stroke_width: int = 1
|
|
263
|
+
#: Blanc entre le libellé d'une boîte et le premier connecteur, sous lui.
|
|
264
|
+
label_gap: int = 40
|
|
265
|
+
#: Habillage par protocole. Un protocole absent de la table reprend les
|
|
266
|
+
#: couleurs de sa boîte, nuancées par :func:`derive_connector_colors`.
|
|
267
|
+
colors: dict[str, Colors] = field(default_factory=dict)
|
|
268
|
+
port: Port = field(default_factory=Port)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass
|
|
272
|
+
class GraphConfig:
|
|
273
|
+
"""Paramètres de mise en page du graphe, section par section."""
|
|
274
|
+
|
|
275
|
+
page: PageConfig = field(default_factory=PageConfig)
|
|
276
|
+
app: AppConfig = field(default_factory=AppConfig)
|
|
277
|
+
main: MainConfig = field(default_factory=MainConfig)
|
|
278
|
+
flow: FlowConfig = field(default_factory=FlowConfig)
|
|
279
|
+
tag: TagConfig = field(default_factory=TagConfig)
|
|
280
|
+
bus: BusConfig = field(default_factory=BusConfig)
|
|
281
|
+
connector: ConnectorConfig = field(default_factory=ConnectorConfig)
|
|
282
|
+
|
|
283
|
+
def to_dict(self) -> dict:
|
|
284
|
+
"""Retourne la configuration sous forme de dictionnaire sérialisable.
|
|
285
|
+
|
|
286
|
+
L'arborescence est celle du fichier de configuration ; un ``Colors``
|
|
287
|
+
y devient ``{text, stroke, fill}``.
|
|
288
|
+
"""
|
|
289
|
+
return _dump(self)
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def from_dict(cls, data: dict) -> "GraphConfig":
|
|
293
|
+
"""Construit une configuration depuis un dictionnaire arborescent.
|
|
294
|
+
|
|
295
|
+
Les clés inconnues et les valeurs invalides sont signalées puis
|
|
296
|
+
écartées, plutôt que de lever : un fichier écrit pour une autre version
|
|
297
|
+
reste exploitable, et un lot de génération n'est jamais interrompu par
|
|
298
|
+
un paramètre fautif.
|
|
299
|
+
"""
|
|
300
|
+
config = _load(cls, data, "")
|
|
301
|
+
config.check_consistency()
|
|
302
|
+
return config
|
|
303
|
+
|
|
304
|
+
def check_consistency(self) -> None:
|
|
305
|
+
"""Signale les réglages qui, valides un à un, se contredisent ensemble.
|
|
306
|
+
|
|
307
|
+
Deux flux d'une même boîte sont espacés de ``2 * flow.margin`` ; les
|
|
308
|
+
pastilles de connecteur, hautes de ``connector.height``, sont centrées
|
|
309
|
+
sur eux. En dessous, elles se chevauchent — et les ports avec elles.
|
|
310
|
+
C'est le seul réglage qui mène à une collision de textes ; il se
|
|
311
|
+
décide à la lecture du fichier, sans attendre le rendu.
|
|
312
|
+
"""
|
|
313
|
+
spacing = 2 * self.flow.margin
|
|
314
|
+
if spacing < self.connector.height:
|
|
315
|
+
logger.warning(
|
|
316
|
+
f"flow.margin = {self.flow.margin} : deux flux ne sont espacés que de "
|
|
317
|
+
f"{spacing} px, moins que la hauteur d'une pastille de connecteur "
|
|
318
|
+
f"(connector.height = {self.connector.height}) — sur un schéma "
|
|
319
|
+
"appli+tech, les pastilles et les ports se chevaucheront"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ----------------------------------------------------------------------
|
|
324
|
+
# Sérialisation
|
|
325
|
+
# ----------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _dump(value: object) -> Any:
|
|
329
|
+
"""Sérialise récursivement, en gardant l'ordre de déclaration des champs."""
|
|
330
|
+
if isinstance(value, Colors):
|
|
331
|
+
return {"text": value.text, "stroke": value.stroke, "fill": value.fill}
|
|
332
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
333
|
+
return {f.name: _dump(getattr(value, f.name)) for f in fields(value)}
|
|
334
|
+
if isinstance(value, dict):
|
|
335
|
+
return {key: _dump(item) for key, item in value.items()}
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
T = TypeVar("T")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _load(cls: type[T], data: object, path: str) -> T:
|
|
343
|
+
"""Construit une dataclass depuis un dictionnaire, champ par champ.
|
|
344
|
+
|
|
345
|
+
Chaque défaut est signalé avec le chemin pointé de la valeur en cause
|
|
346
|
+
(``flow.label.halo.width``), et la valeur par défaut est conservée.
|
|
347
|
+
"""
|
|
348
|
+
default = cls()
|
|
349
|
+
if not isinstance(data, dict):
|
|
350
|
+
logger.warning(f"{path or 'configuration'} : dictionnaire attendu, reçu {excerpt(data)}")
|
|
351
|
+
return default
|
|
352
|
+
|
|
353
|
+
where = f"{path}." if path else ""
|
|
354
|
+
known = {f.name for f in fields(default)} # type: ignore[arg-type]
|
|
355
|
+
unknown = sorted(set(data) - known)
|
|
356
|
+
if unknown:
|
|
357
|
+
logger.warning(
|
|
358
|
+
f"Paramètres de configuration inconnus, ignorés : "
|
|
359
|
+
f"{', '.join(where + str(key) for key in unknown)}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
values = {}
|
|
363
|
+
for f in fields(default): # type: ignore[arg-type]
|
|
364
|
+
if f.name not in data:
|
|
365
|
+
continue
|
|
366
|
+
values[f.name] = _load_field(
|
|
367
|
+
f.type, getattr(default, f.name), f.metadata, data[f.name], f"{where}{f.name}"
|
|
368
|
+
)
|
|
369
|
+
return cls(**values) # type: ignore[arg-type]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _load_field(kind: object, default: Any, metadata: Any, value: object, path: str) -> Any:
|
|
373
|
+
"""Contrôle une valeur selon le type déclaré du champ, ou garde le défaut."""
|
|
374
|
+
if kind is Colors:
|
|
375
|
+
return _load_colors(default, value, path)
|
|
376
|
+
if get_origin(kind) is dict and get_args(kind)[1] is Colors:
|
|
377
|
+
return _load_color_table(value, path)
|
|
378
|
+
if isinstance(kind, type) and is_dataclass(kind):
|
|
379
|
+
return _load(kind, value, path)
|
|
380
|
+
if kind is float:
|
|
381
|
+
if not _is_number(value) or value <= 0:
|
|
382
|
+
logger.warning(f"{path} : nombre strictement positif attendu, reçu {excerpt(value)}")
|
|
383
|
+
return default
|
|
384
|
+
return value
|
|
385
|
+
if kind is int:
|
|
386
|
+
return _load_int(default, metadata, value, path)
|
|
387
|
+
if metadata.get("color"):
|
|
388
|
+
if not _is_color(value):
|
|
389
|
+
logger.warning(f"{path} : couleur invalide ({excerpt(value)})")
|
|
390
|
+
return default
|
|
391
|
+
return value
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _load_int(default: int, metadata: Any, value: object, path: str) -> int:
|
|
396
|
+
"""Contrôle un entier : son type, puis son intervalle.
|
|
397
|
+
|
|
398
|
+
Contrôler le seul type laissait passer un blanc négatif ou une police
|
|
399
|
+
nulle, retenus en silence pour produire un schéma illisible.
|
|
400
|
+
"""
|
|
401
|
+
if not _is_int(value):
|
|
402
|
+
logger.warning(f"{path} : entier attendu, reçu {excerpt(value)}")
|
|
403
|
+
return default
|
|
404
|
+
if metadata.get("signed"):
|
|
405
|
+
return value
|
|
406
|
+
if metadata.get("positive") and value <= 0:
|
|
407
|
+
logger.warning(f"{path} : entier strictement positif attendu, reçu {value}")
|
|
408
|
+
return default
|
|
409
|
+
if value < 0:
|
|
410
|
+
logger.warning(f"{path} : entier positif ou nul attendu, reçu {value}")
|
|
411
|
+
return default
|
|
412
|
+
return value
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _load_colors(default: Colors, value: object, path: str) -> Colors:
|
|
416
|
+
"""Un triplet ``{text, stroke, fill}``, complété depuis le défaut s'il est partiel."""
|
|
417
|
+
if not isinstance(value, dict):
|
|
418
|
+
logger.warning(f"{path} : {{text, stroke, fill}} attendu, reçu {excerpt(value)}")
|
|
419
|
+
return default
|
|
420
|
+
|
|
421
|
+
unknown = sorted(set(value) - set(Colors._fields))
|
|
422
|
+
if unknown:
|
|
423
|
+
logger.warning(f"{path} : couleur(s) inconnue(s), ignorée(s) : {unknown}")
|
|
424
|
+
|
|
425
|
+
retained = {}
|
|
426
|
+
for name in Colors._fields:
|
|
427
|
+
if name not in value:
|
|
428
|
+
continue
|
|
429
|
+
if not _is_color(value[name]):
|
|
430
|
+
logger.warning(f"{path}.{name} : couleur invalide ({excerpt(value[name])})")
|
|
431
|
+
continue
|
|
432
|
+
retained[name] = value[name]
|
|
433
|
+
return default._replace(**retained)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _load_color_table(value: object, path: str) -> dict[str, Colors]:
|
|
437
|
+
"""Contrôle une table d'habillage nommée, entrée par entrée.
|
|
438
|
+
|
|
439
|
+
Sert aussi bien aux tags qu'aux connecteurs : une entrée fautive ou
|
|
440
|
+
incomplète est écartée sans emporter les autres, et le tag ou le protocole
|
|
441
|
+
concerné retombe sur les couleurs de sa boîte.
|
|
442
|
+
"""
|
|
443
|
+
if not isinstance(value, dict):
|
|
444
|
+
logger.warning(f"{path} : dictionnaire attendu, reçu {excerpt(value)}")
|
|
445
|
+
return {}
|
|
446
|
+
|
|
447
|
+
retained = {}
|
|
448
|
+
for name, entry in value.items():
|
|
449
|
+
where = f"{path}.{name}"
|
|
450
|
+
if not isinstance(entry, dict) or set(entry) != set(Colors._fields):
|
|
451
|
+
logger.warning(
|
|
452
|
+
f"{where} : {{text, stroke, fill}} complet attendu, reçu {excerpt(entry)}"
|
|
453
|
+
)
|
|
454
|
+
continue
|
|
455
|
+
invalid = [c for c in entry.values() if not _is_color(c)]
|
|
456
|
+
if invalid:
|
|
457
|
+
logger.warning(f"{where} : couleur(s) invalide(s) {excerpt(invalid)}")
|
|
458
|
+
continue
|
|
459
|
+
retained[str(name)] = Colors(entry["text"], entry["stroke"], entry["fill"])
|
|
460
|
+
return retained
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _is_number(value: object) -> TypeGuard[int | float]:
|
|
464
|
+
return isinstance(value, int | float) and not isinstance(value, bool)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _is_int(value: object) -> TypeGuard[int]:
|
|
468
|
+
"""Un booléen est un ``int`` pour Python, mais jamais une dimension ici."""
|
|
469
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _is_color(value: object) -> bool:
|
|
473
|
+
return isinstance(value, str) and COLOR_PATTERN.fullmatch(value) is not None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ----------------------------------------------------------------------
|
|
477
|
+
# Couleurs dérivées
|
|
478
|
+
# ----------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _shade(color: str, factor: float) -> str:
|
|
482
|
+
"""Éclaircit (`factor > 0`) ou assombrit (`factor < 0`) une couleur.
|
|
483
|
+
|
|
484
|
+
Une couleur nommée (`red`) est rendue telle quelle : sans composantes, il
|
|
485
|
+
n'y a rien à faire varier.
|
|
486
|
+
"""
|
|
487
|
+
if not color.startswith("#"):
|
|
488
|
+
return color
|
|
489
|
+
|
|
490
|
+
digits = color[1:]
|
|
491
|
+
if len(digits) in (3, 4): # notation courte : #rgb, #rgba
|
|
492
|
+
digits = "".join(c * 2 for c in digits)
|
|
493
|
+
if len(digits) not in (6, 8):
|
|
494
|
+
return color
|
|
495
|
+
|
|
496
|
+
canaux = [int(digits[i : i + 2], 16) for i in range(0, 6, 2)]
|
|
497
|
+
cible = 255 if factor > 0 else 0
|
|
498
|
+
varies = [round(c + (cible - c) * abs(factor)) for c in canaux]
|
|
499
|
+
|
|
500
|
+
return "#" + "".join(f"{c:02x}" for c in varies) + digits[6:]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def derive_tag_colors(app_colors: Colors) -> Colors:
|
|
504
|
+
"""Habillage d'un tag déduit des couleurs de son application.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
app_colors: les couleurs de l'application.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Celles du tag : même texte, bordure assombrie, fond éclairci.
|
|
511
|
+
"""
|
|
512
|
+
return Colors(
|
|
513
|
+
app_colors.text,
|
|
514
|
+
_shade(app_colors.stroke, -TAG_BORDER_DARKEN),
|
|
515
|
+
_shade(app_colors.fill, TAG_BACKGROUND_LIGHTEN),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def derive_connector_colors(host_colors: Colors) -> Colors:
|
|
520
|
+
"""Habillage d'un connecteur déduit des couleurs de la boîte qui le porte.
|
|
521
|
+
|
|
522
|
+
Un protocole absent de ``connector.colors`` reste ainsi lisible sans rien
|
|
523
|
+
déclarer : pastille pleine dans le ton de la bordure de sa boîte, texte et
|
|
524
|
+
bord blancs — c'est ce liseré blanc qui la détache de la boîte et donne à
|
|
525
|
+
lire la notion de connecteur.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
host_colors: les couleurs de la boîte hôte.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Celles du connecteur.
|
|
532
|
+
"""
|
|
533
|
+
return Colors("#ffffff", "#ffffff", host_colors.stroke)
|