mmu-cli 0.1.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.
Files changed (96) hide show
  1. mixed_media_utility/__init__.py +1 -0
  2. mixed_media_utility/cadence_previz.py +1997 -0
  3. mixed_media_utility/cli.py +5325 -0
  4. mixed_media_utility/codec_profiles.py +2373 -0
  5. mixed_media_utility/color_calibration.py +4303 -0
  6. mixed_media_utility/color_metrics.py +1314 -0
  7. mixed_media_utility/color_pipeline.py +388 -0
  8. mixed_media_utility/constants.py +6 -0
  9. mixed_media_utility/declaration_de_rush.py +1335 -0
  10. mixed_media_utility/detection/__init__.py +35 -0
  11. mixed_media_utility/detection/aruco.py +463 -0
  12. mixed_media_utility/encode.py +3204 -0
  13. mixed_media_utility/encode_master.py +724 -0
  14. mixed_media_utility/encode_previz.py +1976 -0
  15. mixed_media_utility/extraction.py +1723 -0
  16. mixed_media_utility/extraction_previz.py +1002 -0
  17. mixed_media_utility/ffmpeg_utils.py +629 -0
  18. mixed_media_utility/frame_selection.py +994 -0
  19. mixed_media_utility/gamut_map.py +393 -0
  20. mixed_media_utility/gui/__init__.py +6 -0
  21. mixed_media_utility/gui/__main__.py +102 -0
  22. mixed_media_utility/gui/application.py +125 -0
  23. mixed_media_utility/gui/atelier_scan.py +639 -0
  24. mixed_media_utility/gui/barre_de_vue.py +310 -0
  25. mixed_media_utility/gui/cartes_taches.py +214 -0
  26. mixed_media_utility/gui/catalogue.py +399 -0
  27. mixed_media_utility/gui/chargeur_detections.py +119 -0
  28. mixed_media_utility/gui/chutier.py +1153 -0
  29. mixed_media_utility/gui/coquille.py +1384 -0
  30. mixed_media_utility/gui/depot_projets.py +266 -0
  31. mixed_media_utility/gui/ecran_projet.py +958 -0
  32. mixed_media_utility/gui/executeur.py +193 -0
  33. mixed_media_utility/gui/feuille_application.py +257 -0
  34. mixed_media_utility/gui/jetons.py +432 -0
  35. mixed_media_utility/gui/lecture_detection.py +431 -0
  36. mixed_media_utility/gui/modele_chutier.py +680 -0
  37. mixed_media_utility/gui/modele_projets.py +279 -0
  38. mixed_media_utility/gui/modele_zone_tampon.py +478 -0
  39. mixed_media_utility/gui/preferences_projets.py +98 -0
  40. mixed_media_utility/gui/raster_de_page.py +153 -0
  41. mixed_media_utility/gui/scan_jugement.py +1351 -0
  42. mixed_media_utility/gui/surimpressions.py +520 -0
  43. mixed_media_utility/gui/zone_tampon.py +587 -0
  44. mixed_media_utility/io/__init__.py +67 -0
  45. mixed_media_utility/io/calibration_profile.py +1316 -0
  46. mixed_media_utility/io/encode_manifest.py +940 -0
  47. mixed_media_utility/io/extraction_manifest.py +2018 -0
  48. mixed_media_utility/io/manifest.py +319 -0
  49. mixed_media_utility/io/metadata_matrix.py +237 -0
  50. mixed_media_utility/io/naming.py +1516 -0
  51. mixed_media_utility/io/payload.py +1334 -0
  52. mixed_media_utility/io/pdf_manifest.py +483 -0
  53. mixed_media_utility/io/profile_designation.py +478 -0
  54. mixed_media_utility/io/project_layout.py +660 -0
  55. mixed_media_utility/io/reconstruction.py +1120 -0
  56. mixed_media_utility/io/scan_manifest.py +2216 -0
  57. mixed_media_utility/io/version_ranks.py +164 -0
  58. mixed_media_utility/layout.py +313 -0
  59. mixed_media_utility/makepdf.py +1097 -0
  60. mixed_media_utility/numeric_guards.py +44 -0
  61. mixed_media_utility/page_payload.py +360 -0
  62. mixed_media_utility/page_roles.py +149 -0
  63. mixed_media_utility/page_templates.py +2701 -0
  64. mixed_media_utility/patch_presets.py +1599 -0
  65. mixed_media_utility/patch_values.py +1018 -0
  66. mixed_media_utility/pdf_composition.py +2763 -0
  67. mixed_media_utility/pdf_previz.py +1017 -0
  68. mixed_media_utility/pdf_render.py +417 -0
  69. mixed_media_utility/previz_common.py +361 -0
  70. mixed_media_utility/progression.py +340 -0
  71. mixed_media_utility/project_inventory.py +1213 -0
  72. mixed_media_utility/project_maintenance.py +2995 -0
  73. mixed_media_utility/qr_codes.py +920 -0
  74. mixed_media_utility/relink.py +634 -0
  75. mixed_media_utility/scan_calibrate.py +855 -0
  76. mixed_media_utility/scan_chain.py +240 -0
  77. mixed_media_utility/scan_corrections.py +1166 -0
  78. mixed_media_utility/scan_crop.py +481 -0
  79. mixed_media_utility/scan_detect.py +1395 -0
  80. mixed_media_utility/scan_detection.py +1432 -0
  81. mixed_media_utility/scan_ingest.py +1974 -0
  82. mixed_media_utility/scan_output_frames.py +2238 -0
  83. mixed_media_utility/scan_previz.py +3113 -0
  84. mixed_media_utility/scan_sorting.py +825 -0
  85. mixed_media_utility/scan_write.py +2298 -0
  86. mixed_media_utility/source_confirmation.py +1225 -0
  87. mixed_media_utility/specs/project.schema.json +999 -0
  88. mixed_media_utility/specs/project.schema.legacy.json +52 -0
  89. mixed_media_utility/specs/project.schema.v2-0.json +323 -0
  90. mixed_media_utility/video_metadata.py +416 -0
  91. mmu_cli-0.1.0.dist-info/METADATA +217 -0
  92. mmu_cli-0.1.0.dist-info/RECORD +96 -0
  93. mmu_cli-0.1.0.dist-info/WHEEL +4 -0
  94. mmu_cli-0.1.0.dist-info/entry_points.txt +2 -0
  95. mmu_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
  96. mmu_cli-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +72 -0
@@ -0,0 +1,1351 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Atelier Scan -- le jugement : mode PDF, galerie, vue vignette unique.
3
+
4
+ **Nom de module, et pourquoi celui-la.** Ce module s'est d'abord appele
5
+ ``atelier_scan.py``, et la story 7.3 -- developpee EN PARALLELE sur la meme
6
+ branche -- a pose sous ce nom sa page du **premier temps** de l'atelier
7
+ (deposer, detecter). Les deux stories tiennent les deux temps du meme
8
+ atelier : « L'atelier Scan se deroule en deux temps, jamais en un »
9
+ (``EXPERIENCE.md``). Le nom de fichier suit donc le TEMPS, pas l'atelier --
10
+ ``scan_jugement`` ici, ``atelier_scan`` la-bas -- plutot que de faire porter
11
+ a un seul fichier deux stories, deux agents et deux perimetres de revue.
12
+
13
+ Story 7.4. **Cette story LIT et ne modifie rien** : ni le document de
14
+ detection, ni le manifeste, ni un fichier du projet. Elle est le second point
15
+ de jugement du chemin du scan -- regarder la page, verifier que ce que l'outil
16
+ propose de decouper est bien ce qu'on veut, et le faire **avant qu'un seul
17
+ TIFF ne s'ecrive** (l'ecriture est 7.6, la correction manuelle 7.5).
18
+
19
+ Ce que ce module porte, AC par AC :
20
+
21
+ * **AC 1** -- :class:`VueModePdf` : la page rasterisee sous ses
22
+ surimpressions, les marqueurs manquants NOMMES par identifiant, les
23
+ ``foreign_markers`` LISTES (le document n'en porte aucune coordonnee, donc
24
+ aucun n'est dessine), et ``refusal_reason`` affiche **verbatim**, a cote de
25
+ son code enumere quand le document en porte un ;
26
+ * **AC 2** -- :class:`BandeauDeVerrous` : deux indicateurs distincts, chacun
27
+ son libelle propre, chacun trois etats. « Fondre les deux familles dans un
28
+ seul signe » est un Don't de ``DESIGN.md``, et l'esprit vaut ici mot pour
29
+ mot ;
30
+ * **AC 3** -- :class:`LegendeDeZone` : taille en pixels et timecode, lus du
31
+ document, rendus **hors du contenu d'image** (Don't « Incruster une valeur
32
+ sur l'image ») **et hors du panneau lateral** (``EPIC7-ARB-22``), sur le
33
+ modele de ``frame-thumb.caption-position`` : sous la vignette, hors de
34
+ l'image ;
35
+ * **AC 4** -- :class:`VueGalerie` : les pages du lot par **numero de
36
+ planche** (`EPIC7-ARB-76`, revue de vague 3 : l'ordre du document est celui
37
+ du scanner, et poser les trous dedans les mettait a la mauvaise place),
38
+ leurs trous a leur place, et **aucune image de frame fabriquee** ;
39
+ * **AC 6** -- :class:`VueVignetteUnique` et :class:`PanneauLateralDeVue` ;
40
+ * **AC 8** -- toutes les surfaces d'image LISENT une preference d'image
41
+ unique, portee par la coquille. Aucun ecran ne tient sa propre bascule ni
42
+ son propre etat de calibration (``EPIC7-ARB-69``).
43
+
44
+ **Ce que la story ne fait PAS**, et qui se mesure : aucune poignee, aucune
45
+ loupe, aucun formulaire de QR, aucune annulation (7.5) ; aucun mode lecteur,
46
+ aucun badge de cadence, aucun bus de transport (7.6). Un clic sur une zone
47
+ **designe** -- il n'ouvre rien. La prise de 7.5 existe pourtant deja :
48
+ l'adresse complete ``(read_rank, page_index, slot_index)`` est portee
49
+ **jusqu'au widget**.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from PySide6.QtCore import Qt, Signal
55
+ from PySide6.QtWidgets import (
56
+ QFrame,
57
+ QGraphicsOpacityEffect,
58
+ QGridLayout,
59
+ QHBoxLayout,
60
+ QLabel,
61
+ QPushButton,
62
+ QScrollArea,
63
+ QSizePolicy,
64
+ QSlider,
65
+ QStackedWidget,
66
+ QToolButton,
67
+ QVBoxLayout,
68
+ QWidget,
69
+ )
70
+
71
+ from .. import scan_detect as _scan_detect
72
+ from . import barre_de_vue as _barre
73
+ from . import chutier as _chutier
74
+ from . import jetons
75
+ from . import lecture_detection as _lecture
76
+ from . import modele_chutier as _modele
77
+ from . import raster_de_page as _raster
78
+ from . import surimpressions as _surimpressions
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Etats d'une case de galerie -- la FAMILLE 1 bis : « qu'y a-t-il ici ? »
82
+ # ---------------------------------------------------------------------------
83
+
84
+ CASE_PRESENTE = "presente"
85
+ CASE_ABSENTE = "absente"
86
+ CASE_MIRE = "mire"
87
+
88
+ #: NOM de jeton par etat de case. Une frame absente laisse sa case a sa place
89
+ #: en `state-absent` (« il n'y a rien ici ») ; une mire de remplacement est en
90
+ #: `state-substitute` (« il y a quelque chose ici, mais ce n'est pas ton
91
+ #: image »), **jamais confondue avec une frame complete**.
92
+ JETON_COULEUR_PAR_CASE = {
93
+ CASE_PRESENTE: "border",
94
+ CASE_ABSENTE: "state-absent",
95
+ CASE_MIRE: "state-substitute",
96
+ }
97
+
98
+ #: Cle de catalogue de la legende d'etat d'une case.
99
+ CHAINE_PAR_CASE = {
100
+ CASE_PRESENTE: None,
101
+ CASE_ABSENTE: "scan-case-absente",
102
+ CASE_MIRE: "scan-case-mire",
103
+ }
104
+
105
+
106
+ def etat_de_case(zone) -> str:
107
+ """L'etat d'un emplacement, lu de deux champs fermes et de rien d'autre.
108
+
109
+ L'invariant du coeur (``EPIC5-ARB-8``) est que ``synthetic`` accompagne
110
+ ``frame_path_relative``, ``False`` compris : un drapeau omis se relit
111
+ « vraie frame » et ferait passer une mire « FRAME MANQUANTE » pour un plan
112
+ du film. On lit donc les deux, dans cet ordre.
113
+ """
114
+ if zone.frame_path_relative is None:
115
+ return CASE_ABSENTE
116
+ return CASE_MIRE if zone.synthetic else CASE_PRESENTE
117
+
118
+
119
+ def badge_de_completude_des_planches(document) -> str:
120
+ """L'etat de completude **des planches du lot**, LU du coeur.
121
+
122
+ **FAMILLE 1 des signes** : « que manque-t-il dans ce lot ? ». Elle est
123
+ rendue par ``badge-state``, sous son propre nom, et **sans reference a
124
+ ``EPIC7-ARB-5``**, qui porte le triplet de RATTACHEMENT (``state-glyph``).
125
+ Les deux familles ne se fondent jamais dans un signe unique
126
+ (correction de ``DESIGN.md`` du 2026-08-23, section 1.4).
127
+
128
+ **Le nom dit ce qui est mesure** (`EPIC7-ARB-73`, cause 3) : a l'etape
129
+ `detected` -- la seule que cette vague voit -- ce sont **les planches du
130
+ lot**, jamais les frames. La version d'avant exigeait en plus qu'aucune
131
+ zone ne soit sans ``frame_path_relative``, si bien que le badge valait
132
+ ``incomplet`` sur **tout** document de detection : aucune zone n'en porte
133
+ avant la story d'ecriture, et le seul test qui le voyait vert fabriquait
134
+ un document `reconstructed`, etat que cette story ne produit pas.
135
+
136
+ Le verdict lui-meme n'est plus calcule ici : il vient de
137
+ :func:`scan_detect.completude_des_planches`, qui le rend aussi au chutier.
138
+ Deux surfaces, un seul calcul.
139
+ """
140
+ etat, _manquantes = _scan_detect.completude_des_planches((document.previz,))
141
+ # Sans document il n'y a pas de verdict ; ici il y en a toujours un --
142
+ # la vue tient un document, sans quoi elle n'aurait rien a peindre.
143
+ return etat if etat is not None else _modele.BADGE_INCOMPLET
144
+
145
+
146
+ def planches_manquantes(document) -> tuple[int, ...]:
147
+ """Les ``page_index`` que le lot attend et que le document ne porte pas.
148
+
149
+ LUS du coeur (`EPIC7-ARB-73`), jamais rederives ici. Ce qui vivait a cette
150
+ place jusqu'au 2026-08-25 en etait la seconde redaction, et elle differait
151
+ de la premiere sur deux points mesures : le cardinal attendu (elle prenait
152
+ le ``max(page_count)`` sur **toutes** les pages, y compris une page de
153
+ calibration) et l'origine des index, qu'elle **observait**
154
+ (``0 if 0 in presents else 1``) au lieu de la tenir de la convention du
155
+ depot. Sur un lot dont la planche ``0`` manque -- 0-based, comme tout ce
156
+ que le depot produit --, cette observation nommait manquante la planche
157
+ ``cardinal``, qui n'existe pas, et n'affichait **aucun** trou pour la
158
+ planche ``0``, qui manque reellement.
159
+
160
+ Le trou **garde sa place** dans la grille : c'est :func:`rangs_et_trous`
161
+ qui l'y pose.
162
+ """
163
+ _etat, manquantes = _scan_detect.completude_des_planches((document.previz,))
164
+ return manquantes
165
+
166
+
167
+ def _cle_de_planche(page):
168
+ """La cle d'affichage d'une page : son **numero de planche**.
169
+
170
+ Une page dont le QR n'a pas livre d'index n'a pas de numero de planche :
171
+ elle ne peut pas se ranger entre deux planches numerotees, donc elle passe
172
+ apres toutes celles qui le sont. Le tri est **stable**, donc deux scans de
173
+ la MEME planche (story 5.14) et les pages sans index gardent entre eux
174
+ l'ordre du document -- c'est-a-dire leur ordre de lecture.
175
+ """
176
+ index = page.page.page_index
177
+ return (1, 0) if index is None else (0, index)
178
+
179
+
180
+ def rangs_et_trous(document):
181
+ """La suite des cases de PAGE de la galerie : presentes et trous.
182
+
183
+ Rend une liste de ``(page_lue, None)`` et de ``(None, page_index)``.
184
+
185
+ **La galerie affiche par numero de planche** (`EPIC7-ARB-76`), pas par
186
+ ordre de passage au scanner : l'operatrice raisonne en planches, et une
187
+ galerie qui afficherait 3 puis 1 lui demanderait de faire dans sa tete le
188
+ tri que la machine refuse de faire. Le ``read_rank`` reste porte par le
189
+ document et par chaque case, pour qui en a besoin.
190
+
191
+ C'est ce tri qui rend vraie la seconde promesse : chaque trou **garde sa
192
+ place** -- il s'insere devant la premiere planche presente dont l'index
193
+ est plus grand que le sien, ce qui ne decale aucune des suivantes. Cette
194
+ regle n'a de sens que sur une liste qui MONTE par ``page_index``, et
195
+ jusqu'au 2026-08-25 elle s'appliquait a l'ordre du document, trie par
196
+ ``read_rank`` par le coeur (`scan_detect.pages_du_lot`). Mesure de la
197
+ revue de vague 3, lot de 3 planches dont la 2 manque, scanne a l'envers :
198
+ ``[p3, p1]`` rendait ``['[trou 2]', 'p3', 'p1']`` -- le trou de la
199
+ planche 2 pose **en tete**, c'est-a-dire la promesse exactement inversee.
200
+
201
+ **Depuis `EPIC7-ARB-77` (2026-08-26), le coeur trie deja par
202
+ ``page_index``** : « on range les pages selon l'ordre indique dans le QR »,
203
+ pour toutes les surfaces a la fois. Le tri local ci-dessous devient donc
204
+ **redondant sur les documents du depot** -- et il est **garde**, deliberement.
205
+ Motif : un tri est **idempotent**, retrier une liste deja triee ne rend
206
+ jamais un ordre different, donc les deux ne peuvent pas se contredire. C'est
207
+ ce qui le distingue de la double derivation de la completude
208
+ (`EPIC7-ARB-73`), ou deux CALCULS rendaient deux verdicts opposes. La regle
209
+ d'insertion des trous, elle, exige l'invariant croissant pour etre juste :
210
+ le tenir localement rend cette fonction correcte sur n'importe quelle entree,
211
+ y compris un document venu d'une version qui ne trierait pas.
212
+ """
213
+ suite: list[tuple[object, int | None]] = [
214
+ (page, None) for page in sorted(document.pages, key=_cle_de_planche)
215
+ ]
216
+ # Les pages sans numero de planche forment la queue de la liste : un trou
217
+ # se pose parmi les planches numerotees, jamais apres elles.
218
+ numerotees = sum(
219
+ 1 for page, _trou in suite
220
+ if page is not None and page.page.page_index is not None
221
+ )
222
+ for manquant in planches_manquantes(document):
223
+ position = numerotees
224
+ for indice, (page, _trou) in enumerate(suite):
225
+ if page is not None and page.page.page_index is not None:
226
+ if page.page.page_index > manquant:
227
+ position = indice
228
+ break
229
+ suite.insert(position, (None, manquant))
230
+ numerotees += 1
231
+ return suite
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # AC 2 -- deux verrous, deux indicateurs, jamais un signe unique
236
+ # ---------------------------------------------------------------------------
237
+
238
+ #: NOM de jeton par etat de verrou. Le troisieme n'est pas une fusion des
239
+ #: deux premiers : c'est « le document ne le dit pas », en neutre.
240
+ JETON_COULEUR_PAR_VERROU = {
241
+ _lecture.VERROU_TENU: "state-complete-text",
242
+ _lecture.VERROU_ROMPU: "state-absent-text",
243
+ _lecture.VERROU_INDETERMINE: "text-secondary",
244
+ }
245
+
246
+ #: Redondance non chromatique EXIGEE (meme motif que `badge-state`) : rouge
247
+ #: et ambre sont la paire la plus confusable, et l'outil s'ouvrira a des
248
+ #: videastes dont on ne connait pas la vision des couleurs.
249
+ MARQUE_PAR_VERROU = {
250
+ _lecture.VERROU_TENU: "●",
251
+ _lecture.VERROU_ROMPU: "○",
252
+ _lecture.VERROU_INDETERMINE: "—",
253
+ }
254
+
255
+
256
+ class IndicateurDeVerrou(QFrame):
257
+ """UN verrou : son libelle propre, son detail, son etat parmi trois.
258
+
259
+ Il n'existe pas d'indicateur « des deux verrous » : c'est la formulation
260
+ qui manquait a la maquette et c'est le coeur de cette story.
261
+ """
262
+
263
+ def __init__(self, cle, chaines, parent=None):
264
+ super().__init__(parent)
265
+ self.cle = cle
266
+ self._chaines = chaines
267
+ self.setObjectName(f"verrou-{cle}")
268
+ colonne = QVBoxLayout(self)
269
+ colonne.setContentsMargins(0, 0, 0, 0)
270
+ colonne.setSpacing(jetons.ESPACEMENTS["1"])
271
+ typo_label = jetons.TYPOGRAPHIE["label"]
272
+ self.libelle = QLabel(chaines[f"scan-verrou-{cle}"], self)
273
+ self.libelle.setStyleSheet(
274
+ f"color: {jetons.COULEURS['text-secondary']};"
275
+ f" font-size: {typo_label['taille']}px;"
276
+ )
277
+ self.detail = QLabel(chaines[f"scan-verrou-{cle}-detail"], self)
278
+ self.detail.setStyleSheet(
279
+ f"color: {jetons.COULEURS['text-disabled']};"
280
+ f" font-size: {typo_label['taille']}px;"
281
+ )
282
+ self.valeur = QLabel(self)
283
+ self.valeur.setObjectName(f"verrou-{cle}-valeur")
284
+ colonne.addWidget(self.libelle)
285
+ colonne.addWidget(self.valeur)
286
+ colonne.addWidget(self.detail)
287
+ self.etat = None
288
+ self.couleurs_posees = ()
289
+ self.poser_etat(_lecture.VERROU_INDETERMINE)
290
+
291
+ def poser_etat(self, etat: str) -> None:
292
+ if etat not in _lecture.ETATS_DE_VERROU:
293
+ raise ValueError(f"etat de verrou inconnu : {etat!r}")
294
+ self.etat = etat
295
+ couleur = jetons.COULEURS[JETON_COULEUR_PAR_VERROU[etat]]
296
+ self.couleurs_posees = (
297
+ couleur,
298
+ jetons.COULEURS["text-secondary"],
299
+ jetons.COULEURS["text-disabled"],
300
+ )
301
+ libelle = self._chaines[f"scan-verrou-{etat}"]
302
+ self.valeur.setText(f"{MARQUE_PAR_VERROU[etat]} {libelle}")
303
+ self.valeur.setToolTip(libelle)
304
+ typo = jetons.TYPOGRAPHIE["body"]
305
+ self.valeur.setStyleSheet(
306
+ f"color: {couleur}; font-size: {typo['taille']}px;"
307
+ )
308
+
309
+
310
+ class BandeauDeVerrous(QFrame):
311
+ """Les DEUX indicateurs cote a cote, plus le motif de non-proposition.
312
+
313
+ « La proposition des zones exige les deux au vert » : quand les zones
314
+ sont absentes du document, cette bande dit **lequel** des deux verrous
315
+ manque -- et quand c'est le QR, elle le dit **avec ce motif-la**, pas
316
+ avec « geometrie non resolue ».
317
+ """
318
+
319
+ def __init__(self, chaines, parent=None):
320
+ super().__init__(parent)
321
+ self.setObjectName("bandeau-verrous")
322
+ self._chaines = chaines
323
+ colonne = QVBoxLayout(self)
324
+ colonne.setContentsMargins(0, 0, 0, 0)
325
+ colonne.setSpacing(jetons.ESPACEMENTS["3"])
326
+ ligne = QHBoxLayout()
327
+ ligne.setSpacing(jetons.ESPACEMENTS["7"])
328
+ self.identite = IndicateurDeVerrou("identite", chaines, self)
329
+ self.geometrie = IndicateurDeVerrou("geometrie", chaines, self)
330
+ ligne.addWidget(self.identite)
331
+ ligne.addWidget(self.geometrie)
332
+ ligne.addStretch(1)
333
+ colonne.addLayout(ligne)
334
+ self.motif = QLabel(self)
335
+ self.motif.setObjectName("motif-non-proposition")
336
+ self.motif.setWordWrap(True)
337
+ self.motif.setStyleSheet(
338
+ f"color: {jetons.COULEURS['text-secondary']};"
339
+ f" font-size: {jetons.TYPOGRAPHIE['body']['taille']}px;"
340
+ )
341
+ colonne.addWidget(self.motif)
342
+ self.couleurs_posees = (jetons.COULEURS["text-secondary"],)
343
+
344
+ def poser(self, page) -> None:
345
+ """Poser les deux etats. **Chacun est calcule separement.**"""
346
+ self.identite.poser_etat(_lecture.etat_verrou_identite(page))
347
+ self.geometrie.poser_etat(_lecture.etat_verrou_geometrie(page))
348
+ motif = _lecture.motif_de_non_proposition(page)
349
+ self.motif.setText("" if motif is None else self._chaines[motif])
350
+ self.motif.setVisible(motif is not None)
351
+ self.cle_de_motif = motif
352
+
353
+
354
+ class BandeauDEtatDePage(QFrame):
355
+ """L'etat de page : refus verbatim, code enumere, marqueurs.
356
+
357
+ Trois faits, et chacun a sa regle :
358
+
359
+ * ``refusal_reason`` est affiche **caractere pour caractere**, jamais
360
+ reformule et jamais interprete. **Aucun ecran ne decide quoi que ce
361
+ soit en lisant cette phrase** (``EPIC7-ARB-66``) ;
362
+ * le **code de refus enumere** l'accompagne **quand le document en porte
363
+ un**. Repli explicite et PERMANENT : un document ecrit par une version
364
+ anterieure a la story de coeur 5.27 -- c'est-a-dire, au 2026-08-25,
365
+ **tous** -- n'en porte pas, et la GUI affiche alors la phrase seule.
366
+ Elle n'invente jamais de code et n'en deduit aucun de la phrase ;
367
+ * les marqueurs de coin **manquants** sont nommes **par leur
368
+ identifiant**, sans position dessinee ; les ``foreign_markers`` sont
369
+ **listes** (identifiant + role), jamais dessines -- le document n'en
370
+ porte aucune coordonnee.
371
+
372
+ L'etat « marqueurs incomplets » de la correction ``A7`` **n'existe pas**
373
+ ici : il est retire de la specification par ``EPIC7-ARB-66``, faute de
374
+ producteur au coeur (``MIN_CORNER_MARKERS_REQUIRED`` vaut quatre et
375
+ ``CORNER_MARKER_IDS`` en compte quatre : c'est tout ou rien).
376
+ """
377
+
378
+ def __init__(self, chaines, parent=None):
379
+ super().__init__(parent)
380
+ self.setObjectName("etat-de-page")
381
+ self._chaines = chaines
382
+ colonne = QVBoxLayout(self)
383
+ colonne.setContentsMargins(0, 0, 0, 0)
384
+ colonne.setSpacing(jetons.ESPACEMENTS["2"])
385
+ typo = jetons.TYPOGRAPHIE["body"]
386
+ typo_data = jetons.TYPOGRAPHIE["data"]
387
+
388
+ self.refus = QLabel(self)
389
+ self.refus.setObjectName("refus-phrase")
390
+ self.refus.setWordWrap(True)
391
+ self.refus.setStyleSheet(
392
+ f"color: {jetons.COULEURS['state-absent-text']};"
393
+ f" font-size: {typo['taille']}px;"
394
+ )
395
+ #: Le CODE du coeur, en typographie `data` : il ne se traduit jamais
396
+ #: et ne passe donc pas par le catalogue -- seule son etiquette y est.
397
+ self.code = QLabel(self)
398
+ self.code.setObjectName("refus-code")
399
+ self.code.setStyleSheet(
400
+ f"color: {jetons.COULEURS['text-primary']};"
401
+ f" font-family: {typo_data['famille']};"
402
+ f" font-size: {typo_data['taille']}px;"
403
+ )
404
+ self.marqueurs = QLabel(self)
405
+ self.marqueurs.setObjectName("marqueurs-manquants")
406
+ self.marqueurs.setWordWrap(True)
407
+ self.marqueurs.setStyleSheet(
408
+ f"color: {jetons.COULEURS['text-secondary']};"
409
+ f" font-size: {typo['taille']}px;"
410
+ )
411
+ self.etrangers = QLabel(self)
412
+ self.etrangers.setObjectName("marqueurs-etrangers")
413
+ self.etrangers.setWordWrap(True)
414
+ self.etrangers.setStyleSheet(
415
+ f"color: {jetons.COULEURS['text-secondary']};"
416
+ f" font-size: {typo['taille']}px;"
417
+ )
418
+ for widget in (self.refus, self.code, self.marqueurs, self.etrangers):
419
+ colonne.addWidget(widget)
420
+ self.couleurs_posees = (
421
+ jetons.COULEURS["state-absent-text"],
422
+ jetons.COULEURS["text-primary"],
423
+ jetons.COULEURS["text-secondary"],
424
+ )
425
+
426
+ def poser(self, page) -> None:
427
+ chaines = self._chaines
428
+ phrase = page.page.refusal_reason
429
+ if phrase:
430
+ # VERBATIM : l'etiquette du catalogue, puis la phrase du coeur,
431
+ # intacte. Aucune troncature, aucune reformulation.
432
+ self.refus.setText(f"{chaines['scan-refus-motif']} {phrase}")
433
+ else:
434
+ self.refus.setText("")
435
+ self.refus.setVisible(bool(phrase))
436
+
437
+ code = page.code_de_refus
438
+ self.code.setText("" if code is None else f"{chaines['scan-refus-code']} {code}")
439
+ self.code.setVisible(code is not None)
440
+
441
+ manquants = page.marqueurs_manquants
442
+ if manquants:
443
+ self.marqueurs.setText(
444
+ chaines["scan-marqueurs-manquants"].format(
445
+ identifiants=", ".join(str(identifiant) for identifiant in manquants)
446
+ )
447
+ )
448
+ else:
449
+ self.marqueurs.setText(chaines["scan-marqueurs-tous-presents"])
450
+
451
+ etrangers = page.page.foreign_markers
452
+ if etrangers:
453
+ lignes = [chaines["scan-marqueurs-etrangers"]]
454
+ lignes += [
455
+ chaines["scan-marqueur-etranger-ligne"].format(
456
+ identifiant=marqueur.marker_id, role=marqueur.role
457
+ )
458
+ for marqueur in etrangers
459
+ ]
460
+ self.etrangers.setText("\n".join(lignes))
461
+ else:
462
+ self.etrangers.setText("")
463
+ self.etrangers.setVisible(bool(etrangers))
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # AC 3 -- la legende d'une zone : hors de l'image, hors du panneau lateral
468
+ # ---------------------------------------------------------------------------
469
+
470
+
471
+ class LegendeDeZone(QFrame):
472
+ """Taille en pixels et timecode d'une zone, **sous** la vignette.
473
+
474
+ Deux interdits se croisent et ne laissent qu'un seul endroit : le Don't
475
+ « Incruster une valeur sur l'image » et ``EPIC7-ARB-22`` (« un panneau
476
+ lateral ne repete pas ce que l'image montre deja -- ni le timecode
477
+ affiche dans la scene, ni le numero de frame, ni la position d'une
478
+ poignee »). Le seul endroit ferme par aucun des deux est la legende hors
479
+ image, sur le modele explicite de ``frame-thumb.caption-position``.
480
+
481
+ Les deux valeurs sont LUES du document : la taille des ``crop_*_px``
482
+ (``EPIC7-ARB-15`` : « seul le tc est deduit du qr pas la taille qui
483
+ depend du dpi et de la geometrie »), le timecode de ``frame_timecode``.
484
+ Une zone sans timecode affiche une **absence nommee**, jamais un zero ni
485
+ un tiret muet : un ``--:--:--:--`` sans mot dirait « pas d'image », et ce
486
+ n'est pas ce que le champ dit.
487
+ """
488
+
489
+ designee = Signal(tuple)
490
+
491
+ def __init__(self, page, zone, chaines, parent=None):
492
+ super().__init__(parent)
493
+ self.setObjectName("legende-zone")
494
+ #: L'adresse COMPLETE, portee jusqu'au widget : c'est la prise de 7.5.
495
+ self.adresse = (page.page.read_rank, page.page.page_index, zone.slot_index)
496
+ self.slot_index = zone.slot_index
497
+ colonne = QVBoxLayout(self)
498
+ colonne.setContentsMargins(0, 0, 0, 0)
499
+ colonne.setSpacing(0)
500
+ typo = jetons.TYPOGRAPHIE["data-sm"]
501
+ style = (
502
+ f"color: {jetons.COULEURS['text-secondary']};"
503
+ f" font-family: {typo['famille']};"
504
+ f" font-size: {typo['taille']}px;"
505
+ )
506
+ self.rang = QLabel(
507
+ chaines["scan-zone-legende"].format(slot=zone.slot_index), self
508
+ )
509
+ self.rang.setStyleSheet(style)
510
+ #: `{typography.data}` a chiffres tabulaires : deux tailles de meme
511
+ #: cardinal doivent s'aligner colonne par colonne.
512
+ self.taille = QLabel(
513
+ chaines["scan-zone-taille"].format(
514
+ largeur=zone.crop_w_px, hauteur=zone.crop_h_px
515
+ ),
516
+ self,
517
+ )
518
+ self.taille.setObjectName("zone-taille")
519
+ self.taille.setStyleSheet(style)
520
+ self.timecode = QLabel(
521
+ zone.frame_timecode
522
+ if zone.frame_timecode
523
+ else chaines["scan-zone-timecode-absent"],
524
+ self,
525
+ )
526
+ self.timecode.setObjectName("zone-timecode")
527
+ self.timecode.setStyleSheet(style)
528
+ self.timecode_est_absent = not zone.frame_timecode
529
+ for widget in (self.rang, self.taille, self.timecode):
530
+ colonne.addWidget(widget)
531
+ self.couleurs_posees = (jetons.COULEURS["text-secondary"],)
532
+
533
+ def mousePressEvent(self, evenement): # noqa: N802 -- nom impose par Qt
534
+ """Un clic **designe** cette zone. Il n'ouvre aucune poignee (7.5)."""
535
+ self.designee.emit(self.adresse)
536
+ super().mousePressEvent(evenement)
537
+
538
+
539
+ # ---------------------------------------------------------------------------
540
+ # AC 6 -- le panneau lateral d'une vue : retracte, memorise PAR VUE
541
+ # ---------------------------------------------------------------------------
542
+
543
+
544
+ class PanneauLateralDeVue(QFrame):
545
+ """Un panneau lateral retractable, avec sa poignee de rappel.
546
+
547
+ ``EPIC7-ARB-25`` : « tout panneau lateral d'atelier se retracte » ; « une
548
+ vue qui n'a rien a mettre dans le panneau s'ouvre deja retractee » ;
549
+ « l'etat retracte est memorise **par vue**, pas globalement ». D'ou un
550
+ panneau par vue et jamais un panneau partage : partager l'objet
551
+ partagerait l'etat, ce que l'arbitrage refuse explicitement.
552
+
553
+ **Il ne porte ni taille, ni timecode, ni numero de frame**
554
+ (``EPIC7-ARB-22``) : le banc le mesure sur l'arbre de widgets.
555
+ """
556
+
557
+ def __init__(self, chaines, retracte=False, parent=None):
558
+ super().__init__(parent)
559
+ self.setObjectName("panneau-lateral-de-vue")
560
+ self._chaines = chaines
561
+ self._retracte = bool(retracte)
562
+ ligne = QHBoxLayout(self)
563
+ ligne.setContentsMargins(0, 0, 0, 0)
564
+ ligne.setSpacing(0)
565
+ self.poignee = QPushButton(self)
566
+ self.poignee.setObjectName("poignee-panneau-lateral")
567
+ self.poignee.setFixedWidth(jetons.ESPACEMENTS["hit-min"])
568
+ self.poignee.clicked.connect(self.basculer_le_panneau)
569
+ self.corps = QWidget(self)
570
+ self.corps.setObjectName("corps-panneau-lateral")
571
+ corps_v = QVBoxLayout(self.corps)
572
+ corps_v.setContentsMargins(
573
+ jetons.ESPACEMENTS["panel-pad"], jetons.ESPACEMENTS["panel-pad"],
574
+ jetons.ESPACEMENTS["panel-pad"], jetons.ESPACEMENTS["panel-pad"],
575
+ )
576
+ corps_v.setSpacing(jetons.ESPACEMENTS["4"])
577
+ ligne.addWidget(self.poignee)
578
+ ligne.addWidget(self.corps, 1)
579
+ self.couleurs_posees = (jetons.COULEURS["surface-panel"],)
580
+ self.setStyleSheet(
581
+ "#panneau-lateral-de-vue { background: "
582
+ f"{jetons.COULEURS['surface-panel']}; }}"
583
+ )
584
+ self._appliquer()
585
+
586
+ def ajouter(self, widget) -> None:
587
+ self.corps.layout().addWidget(widget)
588
+
589
+ @property
590
+ def est_retracte(self) -> bool:
591
+ return self._retracte
592
+
593
+ def retracter(self) -> None:
594
+ self._retracte = True
595
+ self._appliquer()
596
+
597
+ def deplier(self) -> None:
598
+ self._retracte = False
599
+ self._appliquer()
600
+
601
+ def basculer_le_panneau(self) -> None:
602
+ """Replier ou deplier CE panneau.
603
+
604
+ Le nom porte son objet : `basculer` tout court serait confondu avec
605
+ la bascule d'IMAGE de la coquille, que ce module n'a pas le droit de
606
+ definir (`EPIC7-ARB-69`), et le grep qui le mesure perdrait son
607
+ tranchant.
608
+ """
609
+ self._retracte = not self._retracte
610
+ self._appliquer()
611
+
612
+ def _appliquer(self) -> None:
613
+ self.corps.setVisible(not self._retracte)
614
+ cle = "scan-panneau-deplier" if self._retracte else "scan-panneau-replier"
615
+ self.poignee.setToolTip(self._chaines[cle])
616
+ self.poignee.setAccessibleName(self._chaines[cle])
617
+ largeur = jetons.ESPACEMENTS["hit-min"] if self._retracte else (
618
+ jetons.ESPACEMENTS["side-panel-width"]
619
+ )
620
+ self.setFixedWidth(largeur)
621
+
622
+
623
+ # ---------------------------------------------------------------------------
624
+ # Socle commun aux trois surfaces d'image
625
+ # ---------------------------------------------------------------------------
626
+
627
+
628
+ class SurfaceDImage(QWidget):
629
+ """Ce que les trois vues d'image ont en commun, et qui n'est ecrit qu'ici.
630
+
631
+ Une barre de vue (LA meme classe, ``A10``), une preference d'image LUE
632
+ d'un etat unique (``EPIC7-ARB-69`` : « aucun ecran ne porte sa propre
633
+ bascule locale, ni un etat de calibration qui lui soit propre ») et un
634
+ panneau lateral dont l'etat retracte est **le sien**.
635
+
636
+ La preference est branchee, jamais recopiee : la surface n'en garde
637
+ aucune copie et relit ``preference.variante`` a chaque rafraichissement.
638
+ """
639
+
640
+ def __init__(self, chaines, preference, fournisseur=None,
641
+ panneau_retracte=False, parent=None):
642
+ super().__init__(parent)
643
+ self._chaines = chaines
644
+ self._preference = preference
645
+ self._fournisseur = fournisseur
646
+ self.barre_de_vue = _barre.BarreDeVue(chaines, self)
647
+ self.panneau_lateral = PanneauLateralDeVue(
648
+ chaines, retracte=panneau_retracte, parent=self
649
+ )
650
+ if preference is not None and hasattr(preference, "variante_changee"):
651
+ preference.variante_changee.connect(self._sur_changement_de_variante)
652
+
653
+ def brancher_la_barre_de_vue(self, scene) -> None:
654
+ """Relier les cinq controles de vue a la scene qu'ils commandent.
655
+
656
+ **Ils n'etaient relies a rien** jusqu'au 2026-08-26 : `BarreDeVue`
657
+ emettait `zoom_change` et `controle_active`, et aucune des trois vues
658
+ d'image ne s'y abonnait. Le curseur de zoom glissait, les quatre
659
+ boutons s'enfoncaient, et l'image ne bougeait pas d'un pixel -- « le
660
+ zoom sur la page pdf n'est absolument pas fonctionnel » (Egan, premier
661
+ essai de terrain). Ce n'etait pas un reglage a corriger : il n'y avait
662
+ aucun cablage du tout.
663
+
664
+ Le branchement vit sur la classe de base et non sur chaque vue :
665
+ c'etait precisement l'absence d'un lieu unique qui rendait l'oubli
666
+ invisible -- trois vues construisent une `BarreDeVue`, aucune ne la
667
+ branchait, et rien dans le code ne le disait.
668
+ """
669
+ self.barre_de_vue.zoom_change.connect(scene.definir_zoom_pourcent)
670
+ self.barre_de_vue.controle_active.connect(
671
+ lambda cle, scene=scene: self._sur_controle_de_vue(cle, scene)
672
+ )
673
+
674
+ def _sur_controle_de_vue(self, cle, scene) -> None:
675
+ """Appliquer un bouton de vue, puis ACCORDER le curseur au resultat.
676
+
677
+ L'accord compte autant que le geste : « pleine largeur » change le
678
+ facteur reel, et un curseur reste a 100 % pendant que l'image en fait
679
+ 180 mentirait sur l'etat de la vue. Le curseur est donc repositionne
680
+ sans reemettre -- sans quoi il redemanderait aussitot son propre
681
+ facteur, arrondi au pourcent, et « taille reelle » ne serait jamais
682
+ exacte.
683
+ """
684
+ gestes = {
685
+ _barre.CONTROLE_AJUSTER: scene.ajuster,
686
+ _barre.CONTROLE_PLEINE_LARGEUR: scene.cadrer_sur_la_largeur,
687
+ _barre.CONTROLE_PLEINE_HAUTEUR: scene.cadrer_sur_la_hauteur,
688
+ _barre.CONTROLE_TAILLE_REELLE: scene.taille_reelle,
689
+ }
690
+ geste = gestes.get(cle)
691
+ if geste is None:
692
+ return
693
+ geste()
694
+ self.barre_de_vue.accorder_le_zoom(round(scene.zoom * 100))
695
+
696
+ @property
697
+ def variante_dimage(self) -> str:
698
+ """La variante courante, **lue** de la preference partagee."""
699
+ if self._preference is None:
700
+ return _raster.IMAGE_BRUTE
701
+ return self._preference.variante
702
+
703
+ def poser_fournisseur(self, fournisseur) -> None:
704
+ self._fournisseur = fournisseur
705
+ self.rafraichir()
706
+
707
+ def image_de(self, page):
708
+ """Le raster d'une page dans la variante COURANTE, ou ``None``."""
709
+ if self._fournisseur is None or page is None:
710
+ return None
711
+ return self._fournisseur.image(page, self.variante_dimage)
712
+
713
+ def _sur_changement_de_variante(self, _variante) -> None:
714
+ self.rafraichir()
715
+
716
+ def rafraichir(self) -> None: # pragma: no cover -- surcharge par les vues
717
+ """Redessiner ce que la surface montre. Surchargee par chaque vue."""
718
+
719
+
720
+ # ---------------------------------------------------------------------------
721
+ # AC 1, 2, 3 -- le mode PDF
722
+ # ---------------------------------------------------------------------------
723
+
724
+
725
+ class VueModePdf(SurfaceDImage):
726
+ """La page rasterisee sous ses surimpressions, et ce que la page dit.
727
+
728
+ Ordre de haut en bas : controles de vue (**au-dessus** de la scene), la
729
+ scene (``viewer-stage``, et **rien d'autre n'entre dans son cadre**),
730
+ puis, HORS de l'image, les legendes de zones et l'etat de page.
731
+ """
732
+
733
+ zone_designee = Signal(tuple)
734
+
735
+ def __init__(self, chaines, preference=None, fournisseur=None, parent=None):
736
+ super().__init__(chaines, preference, fournisseur, parent=parent)
737
+ self.setObjectName("vue-mode-pdf")
738
+ self.page = None
739
+ self.designation = None
740
+ ligne = QHBoxLayout(self)
741
+ ligne.setContentsMargins(0, 0, 0, 0)
742
+ ligne.setSpacing(0)
743
+ colonne = QVBoxLayout()
744
+ colonne.setContentsMargins(
745
+ jetons.ESPACEMENTS["gutter"], jetons.ESPACEMENTS["gutter"],
746
+ jetons.ESPACEMENTS["gutter"], jetons.ESPACEMENTS["gutter"],
747
+ )
748
+ colonne.setSpacing(jetons.ESPACEMENTS["5"])
749
+ colonne.addWidget(self.barre_de_vue)
750
+ self.scene = _surimpressions.ScenePlanche(self)
751
+ self.brancher_la_barre_de_vue(self.scene)
752
+ self.scene.setSizePolicy(
753
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
754
+ )
755
+ colonne.addWidget(self.scene, 1)
756
+
757
+ #: Les legendes de zones : **hors** du contenu d'image.
758
+ self.legendes = QWidget(self)
759
+ self.legendes.setObjectName("legendes-de-zones")
760
+ self._legendes_ligne = QHBoxLayout(self.legendes)
761
+ self._legendes_ligne.setContentsMargins(0, 0, 0, 0)
762
+ self._legendes_ligne.setSpacing(jetons.ESPACEMENTS["gutter"])
763
+ colonne.addWidget(self.legendes)
764
+
765
+ self.verrous = BandeauDeVerrous(chaines, self)
766
+ colonne.addWidget(self.verrous)
767
+ self.etat_de_page = BandeauDEtatDePage(chaines, self)
768
+ colonne.addWidget(self.etat_de_page)
769
+ ligne.addLayout(colonne, 1)
770
+ ligne.addWidget(self.panneau_lateral)
771
+ self._apercu_indisponible = QLabel(chaines["scan-apercu-page-indisponible"], self)
772
+ self._apercu_indisponible.setObjectName("apercu-indisponible")
773
+ self._apercu_indisponible.setStyleSheet(
774
+ f"color: {jetons.COULEURS['text-secondary']};"
775
+ )
776
+ colonne.addWidget(self._apercu_indisponible)
777
+ self._apercu_indisponible.setVisible(False)
778
+
779
+ def poser(self, page) -> None:
780
+ """Poser la page a juger. **Aucune ecriture, aucune derivation.**"""
781
+ self.page = page
782
+ self.designation = None
783
+ self.verrous.poser(page)
784
+ self.etat_de_page.poser(page)
785
+ self._poser_les_legendes(page)
786
+ self.rafraichir()
787
+
788
+ def rafraichir(self) -> None:
789
+ if self.page is None:
790
+ return
791
+ image = self.image_de(self.page)
792
+ self.scene.poser(self.page, image)
793
+ self._apercu_indisponible.setVisible(image is None)
794
+
795
+ @property
796
+ def plan_de_surimpressions(self):
797
+ """Le plan reellement pose sur l'image de cette vue."""
798
+ return self.scene.contenu.plan
799
+
800
+ def _poser_les_legendes(self, page) -> None:
801
+ while self._legendes_ligne.count():
802
+ element = self._legendes_ligne.takeAt(0)
803
+ widget = element.widget()
804
+ if widget is not None:
805
+ widget.setParent(None)
806
+ widget.deleteLater()
807
+ for zone in page.page.frame_zones:
808
+ legende = LegendeDeZone(page, zone, self._chaines, self.legendes)
809
+ legende.designee.connect(self.designer)
810
+ self._legendes_ligne.addWidget(legende)
811
+ self._legendes_ligne.addStretch(1)
812
+
813
+ def designer(self, adresse) -> None:
814
+ """Designer une zone. **Aucune poignee n'est instanciee** (7.5)."""
815
+ self.designation = tuple(adresse)
816
+ self.zone_designee.emit(self.designation)
817
+
818
+
819
+ # ---------------------------------------------------------------------------
820
+ # AC 4 -- la galerie
821
+ # ---------------------------------------------------------------------------
822
+
823
+
824
+ class CaseDEmplacement(QFrame):
825
+ """Un emplacement de la grille : son etat, sa legende. **Aucune image.**
826
+
827
+ « Aucune image de frame n'est fabriquee » : les images legeres de la
828
+ detection sont une dependance nommee **non livree**, et recadrer le
829
+ raster de page pour simuler une vignette creerait une **seconde source**
830
+ pour la meme frame -- ce que le produit a refuse le 2026-08-17. Une case
831
+ dont l'image n'existe pas affiche donc son etat et sa legende, et **rien
832
+ qui ressemble a une image**.
833
+
834
+ La case est ecrite pour accueillir cette image sans changer de forme le
835
+ jour ou elle arrivera : la legende est deja **sous** la vignette, hors de
836
+ l'image (``frame-thumb.caption-position``), et les bordures d'etat sont
837
+ deja celles de ``frame-thumb``.
838
+ """
839
+
840
+ ouverte = Signal(tuple)
841
+
842
+ def __init__(self, page, zone, chaines, parent=None):
843
+ super().__init__(parent)
844
+ self.setObjectName("case-emplacement")
845
+ self.adresse = (page.page.read_rank, page.page.page_index, zone.slot_index)
846
+ self.slot_index = zone.slot_index
847
+ self.etat = etat_de_case(zone)
848
+ self.jeton_bordure = JETON_COULEUR_PAR_CASE[self.etat]
849
+ self.designee = False
850
+ colonne = QVBoxLayout(self)
851
+ colonne.setContentsMargins(0, 0, 0, 0)
852
+ colonne.setSpacing(jetons.ESPACEMENTS["2"])
853
+ #: La vignette : un cadre a coins DROITS (`frame-thumb.radius` vaut
854
+ #: `{rounded.none}`), vide tant que les images legeres n'existent pas.
855
+ self.vignette = QFrame(self)
856
+ self.vignette.setObjectName("vignette-emplacement")
857
+ self.vignette.setMinimumSize(jetons.ESPACEMENTS["8"], jetons.ESPACEMENTS["8"])
858
+ self.vignette.setStyleSheet(
859
+ "#vignette-emplacement { border: 1px solid "
860
+ f"{jetons.COULEURS[self.jeton_bordure]};"
861
+ f" border-radius: {jetons.RAYON_CONTENU_IMAGE}px; }}"
862
+ )
863
+ colonne.addWidget(self.vignette, 1)
864
+ cle_etat = CHAINE_PAR_CASE[self.etat]
865
+ typo = jetons.TYPOGRAPHIE["data-sm"]
866
+ self.legende = LegendeDeZone(page, zone, chaines, self)
867
+ colonne.addWidget(self.legende)
868
+ self.etat_libelle = QLabel(
869
+ "" if cle_etat is None else chaines[cle_etat], self
870
+ )
871
+ self.etat_libelle.setObjectName("case-etat")
872
+ # Le libelle d'etat porte la variante `-text` de la chromie de la
873
+ # case quand elle en a une (plancher 4,5:1 en 11 px), le neutre
874
+ # secondaire sinon : `border` n'est pas une chromie et n'a pas de
875
+ # variante de texte.
876
+ jeton_texte = (
877
+ f"{self.jeton_bordure}-text"
878
+ if f"{self.jeton_bordure}-text" in jetons.COULEURS
879
+ else "text-secondary"
880
+ )
881
+ self.couleur_du_libelle = jetons.COULEURS[jeton_texte]
882
+ self.etat_libelle.setStyleSheet(
883
+ f"color: {self.couleur_du_libelle};"
884
+ f" font-size: {typo['taille']}px;"
885
+ )
886
+ self.etat_libelle.setVisible(cle_etat is not None)
887
+ colonne.addWidget(self.etat_libelle)
888
+ self.couleurs_posees = (
889
+ jetons.COULEURS[self.jeton_bordure],
890
+ self.couleur_du_libelle,
891
+ )
892
+ self._effet_opacite = QGraphicsOpacityEffect(self)
893
+ self.setGraphicsEffect(self._effet_opacite)
894
+ self._appliquer_l_opacite()
895
+
896
+ def _appliquer_l_opacite(self) -> None:
897
+ """`frame-thumb.opacity-deselected` : une case non designee s'efface.
898
+
899
+ Elle s'EFFACE, elle ne disparait pas : 0,32 est lu du module de
900
+ jetons, jamais ecrit ici.
901
+ """
902
+ self.opacite = (
903
+ 1.0 if self.designee else jetons.OPACITE_VIGNETTE_NON_DESIGNEE
904
+ )
905
+ self._effet_opacite.setOpacity(self.opacite)
906
+
907
+ def designer(self, designee=True) -> None:
908
+ self.designee = bool(designee)
909
+ self._appliquer_l_opacite()
910
+
911
+ def mousePressEvent(self, evenement): # noqa: N802 -- nom impose par Qt
912
+ """Un clic ouvre la vue vignette unique sur CETTE frame."""
913
+ self.ouverte.emit(self.adresse)
914
+ super().mousePressEvent(evenement)
915
+
916
+
917
+ class CaseDePageManquante(QFrame):
918
+ """Le trou d'une planche absente : il **garde sa place** dans la grille."""
919
+
920
+ def __init__(self, page_index, chaines, parent=None):
921
+ super().__init__(parent)
922
+ self.setObjectName("case-page-manquante")
923
+ self.page_index = page_index
924
+ self.etat = CASE_ABSENTE
925
+ self.jeton_bordure = JETON_COULEUR_PAR_CASE[CASE_ABSENTE]
926
+ colonne = QVBoxLayout(self)
927
+ colonne.setContentsMargins(0, 0, 0, 0)
928
+ colonne.setSpacing(0)
929
+ libelle = QLabel(
930
+ chaines["scan-galerie-page-manquante"].format(planche=page_index), self
931
+ )
932
+ libelle.setWordWrap(True)
933
+ libelle.setStyleSheet(
934
+ f"color: {jetons.COULEURS['state-absent-text']};"
935
+ f" font-size: {jetons.TYPOGRAPHIE['data-sm']['taille']}px;"
936
+ )
937
+ colonne.addWidget(libelle)
938
+ self.setStyleSheet(
939
+ "#case-page-manquante { border: 1px solid "
940
+ f"{jetons.COULEURS[self.jeton_bordure]};"
941
+ f" border-radius: {jetons.RAYON_CONTENU_IMAGE}px; }}"
942
+ )
943
+ self.couleurs_posees = (
944
+ jetons.COULEURS[self.jeton_bordure],
945
+ jetons.COULEURS["state-absent-text"],
946
+ )
947
+
948
+
949
+ class LigneDePageDeGalerie(QFrame):
950
+ """Une planche de la galerie : son apercu de PAGE, puis ses emplacements.
951
+
952
+ L'apercu est celui de la **page entiere**, tel que le scan l'a lue -- ce
953
+ n'est ni une image de frame fabriquee, ni un recadrage du raster : c'est
954
+ la page, en entier, la seule image que le document designe. C'est par lui
955
+ que la galerie respecte la bascule brut / corrige (AC 8), et c'est ce qui
956
+ permet de mesurer que cette bascule est **globale** sur deux surfaces.
957
+ """
958
+
959
+ case_ouverte = Signal(tuple)
960
+
961
+ def __init__(self, page, chaines, parent=None):
962
+ super().__init__(parent)
963
+ self.setObjectName("ligne-page-galerie")
964
+ self.page = page
965
+ self._chaines = chaines
966
+ colonne = QVBoxLayout(self)
967
+ colonne.setContentsMargins(0, 0, 0, 0)
968
+ colonne.setSpacing(jetons.ESPACEMENTS["3"])
969
+ entete = QHBoxLayout()
970
+ entete.setSpacing(jetons.ESPACEMENTS["5"])
971
+ index = page.page.page_index
972
+ titre = (
973
+ chaines["scan-galerie-planche"].format(planche=index)
974
+ if index is not None
975
+ else chaines["scan-galerie-planche-inconnue"].format(
976
+ rang=page.page.read_rank
977
+ )
978
+ )
979
+ self.titre = QLabel(titre, self)
980
+ self.titre.setStyleSheet(
981
+ f"color: {jetons.COULEURS['text-primary']};"
982
+ f" font-size: {jetons.TYPOGRAPHIE['label']['taille']}px;"
983
+ )
984
+ entete.addWidget(self.titre)
985
+ #: FAMILLE 2 -- le rattachement. Une planche dont le QR n'a pas livre
986
+ #: son index n'est rattachee a rien : elle porte le GLYPHE, jamais un
987
+ #: badge de completude, qui repond a une autre question.
988
+ self.glyphe = None
989
+ if index is None:
990
+ self.glyphe = _chutier.GlypheDeRattachement(
991
+ _modele.GLYPHE_NON_RATTACHE, chaines, self
992
+ )
993
+ entete.addWidget(self.glyphe)
994
+ entete.addStretch(1)
995
+ colonne.addLayout(entete)
996
+
997
+ self.apercu = _surimpressions.ScenePlanche(self)
998
+ #: Hauteur d'apercu a 100 %. Gardee nommee parce que le zoom de
999
+ #: vignettes la MULTIPLIE : sans reference conservee, un zoom applique
1000
+ #: deux fois de suite composerait au lieu de remplacer, et redescendre
1001
+ #: a 100 % ne rendrait plus la taille de depart.
1002
+ # Six fois `8` et non deux (correctif du 2026-08-26) : a 64 px de
1003
+ # haut, l'apercu d'une planche A4 faisait 45 px de large -- on n'y
1004
+ # distinguait ni les zones proposees ni les marqueurs, c'est-a-dire
1005
+ # rien de ce que la galerie existe pour montrer. Mesure sur capture.
1006
+ self.hauteur_apercu_de_reference = jetons.ESPACEMENTS["8"] * 6
1007
+ self.apercu.setMinimumHeight(self.hauteur_apercu_de_reference)
1008
+ colonne.addWidget(self.apercu)
1009
+
1010
+ self.cases: list[CaseDEmplacement] = []
1011
+ grille_widget = QWidget(self)
1012
+ grille_widget.setObjectName("grille-emplacements")
1013
+ self.grille = QGridLayout(grille_widget)
1014
+ self.grille.setContentsMargins(0, 0, 0, 0)
1015
+ self.grille.setSpacing(jetons.ESPACEMENTS["gutter"])
1016
+ for colonne_indice, zone in enumerate(page.page.frame_zones):
1017
+ case = CaseDEmplacement(page, zone, chaines, grille_widget)
1018
+ case.ouverte.connect(self.case_ouverte.emit)
1019
+ self.grille.addWidget(case, 0, colonne_indice)
1020
+ self.cases.append(case)
1021
+ colonne.addWidget(grille_widget)
1022
+ self.couleurs_posees = (jetons.COULEURS["text-primary"],)
1023
+
1024
+ def poser_image(self, image) -> None:
1025
+ self.apercu.poser(self.page, image)
1026
+
1027
+
1028
+ class VueGalerie(SurfaceDImage):
1029
+ """Les pages du lot et leurs trous. **Aucune image de frame fabriquee.**"""
1030
+
1031
+ case_ouverte = Signal(tuple)
1032
+
1033
+ def __init__(self, chaines, preference=None, fournisseur=None, parent=None):
1034
+ super().__init__(chaines, preference, fournisseur, parent=parent)
1035
+ self.setObjectName("vue-galerie")
1036
+ self.document = None
1037
+ self.lignes: list[LigneDePageDeGalerie] = []
1038
+ self.trous: list[CaseDePageManquante] = []
1039
+ ligne_h = QHBoxLayout(self)
1040
+ ligne_h.setContentsMargins(0, 0, 0, 0)
1041
+ ligne_h.setSpacing(0)
1042
+ colonne = QVBoxLayout()
1043
+ colonne.setContentsMargins(
1044
+ jetons.ESPACEMENTS["gutter"], jetons.ESPACEMENTS["gutter"],
1045
+ jetons.ESPACEMENTS["gutter"], jetons.ESPACEMENTS["gutter"],
1046
+ )
1047
+ colonne.setSpacing(jetons.ESPACEMENTS["5"])
1048
+ entete = QHBoxLayout()
1049
+ entete.setSpacing(jetons.ESPACEMENTS["5"])
1050
+ entete.addWidget(self.barre_de_vue, 1)
1051
+ #: Le zoom de VIGNETTES, continu et sans paliers, **au-dessus** de la
1052
+ #: scene (`EXPERIENCE.md`, Interaction Primitives).
1053
+ self.zoom_vignettes = QSlider(Qt.Orientation.Horizontal, self)
1054
+ self.zoom_vignettes.setObjectName("zoom-vignettes")
1055
+ self.zoom_vignettes.setRange(
1056
+ _barre.ZOOM_MIN_POURCENT, _barre.ZOOM_MAX_POURCENT
1057
+ )
1058
+ self.zoom_vignettes.setValue(_barre.ZOOM_DEFAUT_POURCENT)
1059
+ self.zoom_vignettes.setSingleStep(1)
1060
+ self.zoom_vignettes.setPageStep(1)
1061
+ self.zoom_vignettes.setTickPosition(QSlider.TickPosition.NoTicks)
1062
+ self.zoom_vignettes.setToolTip(chaines["scan-galerie-zoom"])
1063
+ self.zoom_vignettes.setAccessibleName(chaines["scan-galerie-zoom"])
1064
+ # **Il n'etait relie a rien** jusqu'au 2026-08-26 : le curseur glissait
1065
+ # et aucune vignette ne changeait de taille. Meme defaut, meme jour,
1066
+ # que le zoom de la vue page -- un signal emis que personne n'ecoute.
1067
+ self.zoom_vignettes.valueChanged.connect(self.definir_zoom_vignettes)
1068
+ entete.addWidget(self.zoom_vignettes)
1069
+ self.badge = None
1070
+ self._entete = entete
1071
+ colonne.addLayout(entete)
1072
+
1073
+ self.defilement = QScrollArea(self)
1074
+ self.defilement.setWidgetResizable(True)
1075
+ self.contenu_defilant = QWidget()
1076
+ self.colonne_pages = QVBoxLayout(self.contenu_defilant)
1077
+ self.colonne_pages.setContentsMargins(0, 0, 0, 0)
1078
+ self.colonne_pages.setSpacing(jetons.ESPACEMENTS["7"])
1079
+ self.defilement.setWidget(self.contenu_defilant)
1080
+ colonne.addWidget(self.defilement, 1)
1081
+ ligne_h.addLayout(colonne, 1)
1082
+ ligne_h.addWidget(self.panneau_lateral)
1083
+
1084
+ def definir_zoom_vignettes(self, pourcent) -> None:
1085
+ """Mettre les apercus de planche a l'echelle demandee.
1086
+
1087
+ La galerie montre des PAGES entieres, pas des frames recadrees (aucune
1088
+ vignette de frame n'existe avant la story 7.6) : ce zoom-ci agit donc
1089
+ sur la hauteur des apercus, et c'est bien « la taille des vignettes »
1090
+ au sens ou l'operatrice le demande -- voir plus grand ce qu'elle juge.
1091
+
1092
+ Chaque hauteur est recalculee depuis sa REFERENCE et non depuis sa
1093
+ valeur courante : multiplier la valeur courante composerait les zooms
1094
+ successifs, et redescendre a 100 % ne rendrait pas la taille de depart.
1095
+ """
1096
+ facteur = max(1, int(pourcent)) / 100.0
1097
+ for ligne in self.lignes:
1098
+ ligne.apercu.setMinimumHeight(
1099
+ int(ligne.hauteur_apercu_de_reference * facteur)
1100
+ )
1101
+
1102
+ def poser(self, document) -> None:
1103
+ """Poser le document. **L'ordre rendu est celui du document.**"""
1104
+ self.document = document
1105
+ for ancien in self.lignes + self.trous:
1106
+ ancien.setParent(None)
1107
+ ancien.deleteLater()
1108
+ self.lignes = []
1109
+ self.trous = []
1110
+ while self.colonne_pages.count():
1111
+ element = self.colonne_pages.takeAt(0)
1112
+ widget = element.widget()
1113
+ if widget is not None:
1114
+ widget.setParent(None)
1115
+ if self.badge is not None:
1116
+ self.badge.setParent(None)
1117
+ self.badge.deleteLater()
1118
+ self.badge = _chutier.BadgeDeCompletude(
1119
+ badge_de_completude_des_planches(document), self._chaines,
1120
+ parent=self
1121
+ )
1122
+ self._entete.insertWidget(0, self.badge)
1123
+ for page, trou in rangs_et_trous(document):
1124
+ if page is not None:
1125
+ ligne = LigneDePageDeGalerie(page, self._chaines, self.contenu_defilant)
1126
+ ligne.case_ouverte.connect(self.case_ouverte.emit)
1127
+ self.colonne_pages.addWidget(ligne)
1128
+ self.lignes.append(ligne)
1129
+ else:
1130
+ case = CaseDePageManquante(trou, self._chaines, self.contenu_defilant)
1131
+ self.colonne_pages.addWidget(case)
1132
+ self.trous.append(case)
1133
+ self.colonne_pages.addStretch(1)
1134
+ # Le zoom courant s'applique aux lignes qui viennent d'etre posees :
1135
+ # changer de lot ne doit pas ramener sournoisement les apercus a 100 %
1136
+ # pendant que le curseur, lui, reste ou l'operatrice l'a laisse.
1137
+ self.definir_zoom_vignettes(self.zoom_vignettes.value())
1138
+ self.rafraichir()
1139
+
1140
+ def rafraichir(self) -> None:
1141
+ for ligne in self.lignes:
1142
+ ligne.poser_image(self.image_de(ligne.page))
1143
+
1144
+ @property
1145
+ def suite_rendue(self) -> tuple:
1146
+ """Ce que la galerie rend, dans l'ordre : pages presentes et trous.
1147
+
1148
+ Publie pour le banc : c'est ce qui permet de mesurer que le trou est
1149
+ **a sa place** et que les suivantes ne sont pas decalees.
1150
+ """
1151
+ rendus = []
1152
+ for indice in range(self.colonne_pages.count()):
1153
+ widget = self.colonne_pages.itemAt(indice).widget()
1154
+ if isinstance(widget, LigneDePageDeGalerie):
1155
+ rendus.append(("page", widget.page.adresse))
1156
+ elif isinstance(widget, CaseDePageManquante):
1157
+ rendus.append(("trou", widget.page_index))
1158
+ return tuple(rendus)
1159
+
1160
+
1161
+ # ---------------------------------------------------------------------------
1162
+ # AC 6 -- la vue vignette unique
1163
+ # ---------------------------------------------------------------------------
1164
+
1165
+
1166
+ class VueVignetteUnique(SurfaceDImage):
1167
+ """La frame seule, plein panneau, zoom dedans (``EPIC7-ARB-18``).
1168
+
1169
+ « C'est une vue a part entiere, la troisieme apres la galerie et le
1170
+ lecteur, et non un agrandissement de la grille. » Son panneau lateral
1171
+ **s'ouvre deja retracte** : cette vue n'a rien a y mettre
1172
+ (``EPIC7-ARB-25``), et l'etat retracte lui appartient -- revenir a la
1173
+ galerie puis rouvrir une autre frame ne change pas l'etat de la galerie.
1174
+ """
1175
+
1176
+ def __init__(self, chaines, preference=None, fournisseur=None, parent=None):
1177
+ # Le panneau s'ouvre RETRACTE : c'est le defaut de cette vue-la.
1178
+ super().__init__(
1179
+ chaines, preference, fournisseur, panneau_retracte=True, parent=parent
1180
+ )
1181
+ self.setObjectName("vue-vignette-unique")
1182
+ self.adresse = None
1183
+ self.page = None
1184
+ self.zone = None
1185
+ ligne = QHBoxLayout(self)
1186
+ ligne.setContentsMargins(0, 0, 0, 0)
1187
+ ligne.setSpacing(0)
1188
+ colonne = QVBoxLayout()
1189
+ colonne.setContentsMargins(
1190
+ jetons.ESPACEMENTS["gutter"], jetons.ESPACEMENTS["gutter"],
1191
+ jetons.ESPACEMENTS["gutter"], jetons.ESPACEMENTS["gutter"],
1192
+ )
1193
+ colonne.setSpacing(jetons.ESPACEMENTS["5"])
1194
+ self.titre = QLabel(self)
1195
+ self.titre.setObjectName("titre-vignette-unique")
1196
+ self.titre.setStyleSheet(
1197
+ f"color: {jetons.COULEURS['text-primary']};"
1198
+ f" font-size: {jetons.TYPOGRAPHIE['title']['taille']}px;"
1199
+ )
1200
+ colonne.addWidget(self.titre)
1201
+ colonne.addWidget(self.barre_de_vue)
1202
+ self.scene = _surimpressions.ScenePlanche(self)
1203
+ self.brancher_la_barre_de_vue(self.scene)
1204
+ self.scene.setSizePolicy(
1205
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
1206
+ )
1207
+ colonne.addWidget(self.scene, 1)
1208
+ self.legende = None
1209
+ self._colonne = colonne
1210
+ ligne.addLayout(colonne, 1)
1211
+ ligne.addWidget(self.panneau_lateral)
1212
+ self.couleurs_posees = (jetons.COULEURS["text-primary"],)
1213
+
1214
+ def poser(self, page, zone) -> None:
1215
+ self.page = page
1216
+ self.zone = zone
1217
+ self.adresse = (page.page.read_rank, page.page.page_index, zone.slot_index)
1218
+ self.titre.setText(
1219
+ self._chaines["scan-frame-titre"].format(
1220
+ slot=zone.slot_index,
1221
+ planche=page.page.page_index
1222
+ if page.page.page_index is not None
1223
+ else page.page.read_rank,
1224
+ )
1225
+ )
1226
+ if self.legende is not None:
1227
+ self.legende.setParent(None)
1228
+ self.legende.deleteLater()
1229
+ # La legende reste SOUS l'image, hors du contenu, et **hors du
1230
+ # panneau lateral** : les deux interdits d'EPIC7-ARB-22 et du Don't
1231
+ # « Incruster une valeur sur l'image » se croisent ici aussi.
1232
+ self.legende = LegendeDeZone(page, zone, self._chaines, self)
1233
+ self._colonne.addWidget(self.legende)
1234
+ self.rafraichir()
1235
+
1236
+ def rafraichir(self) -> None:
1237
+ if self.page is None:
1238
+ return
1239
+ self.scene.poser(self.page, self.image_de(self.page))
1240
+
1241
+
1242
+ # ---------------------------------------------------------------------------
1243
+ # L'atelier : les trois vues, les onglets, un document
1244
+ # ---------------------------------------------------------------------------
1245
+
1246
+
1247
+ class AtelierScanJugement(QWidget):
1248
+ """Le second point de jugement du chemin du scan, en trois vues.
1249
+
1250
+ Onglets de cette story : ``page`` et ``galerie``, plus ``frame`` **la ou
1251
+ la vue vignette unique a un objet**. L'onglet ``lecteur`` n'existe pas
1252
+ ici : c'est 7.6. Un onglet sans objet est ABSENT, un controle
1253
+ temporairement indisponible est GRISE (``EPIC7-ARB-24``).
1254
+ """
1255
+
1256
+ def __init__(self, chaines, preference=None, fournisseur=None, parent=None):
1257
+ super().__init__(parent)
1258
+ self.setObjectName("atelier-scan-jugement")
1259
+ self._chaines = chaines
1260
+ self.document = None
1261
+ colonne = QVBoxLayout(self)
1262
+ colonne.setContentsMargins(0, 0, 0, 0)
1263
+ colonne.setSpacing(0)
1264
+ self.onglets = _barre.OngletsDeVue(chaines, self)
1265
+ # Cette barre porte deux ou trois onglets, jamais plus : elle ne
1266
+ # defile pas. Qt lui pose pourtant deux boutons de defilement dont il
1267
+ # nomme l'accessibilite en ANGLAIS (« Scroll Left », « Scroll
1268
+ # Right ») -- des chaines qui n'existent dans aucun catalogue et que
1269
+ # l'anglais hors v1 (`EPIC7-ARB-52`) interdit dans la fenetre. On les
1270
+ # eteint et on efface leur nom : un controle masque et sans fonction
1271
+ # n'a rien a nommer, et la coquille cesse de porter du texte qu'aucune
1272
+ # substitution de catalogue n'atteint.
1273
+ self.onglets.setUsesScrollButtons(False)
1274
+ for bouton in self.onglets.findChildren(QToolButton):
1275
+ bouton.setAccessibleName("")
1276
+ bouton.setToolTip("")
1277
+ colonne.addWidget(self.onglets)
1278
+ self.pile = QStackedWidget(self)
1279
+ self.vue_page = VueModePdf(chaines, preference, fournisseur, self)
1280
+ self.vue_galerie = VueGalerie(chaines, preference, fournisseur, self)
1281
+ self.vue_frame = VueVignetteUnique(chaines, preference, fournisseur, self)
1282
+ for vue in (self.vue_page, self.vue_galerie, self.vue_frame):
1283
+ self.pile.addWidget(vue)
1284
+ colonne.addWidget(self.pile, 1)
1285
+ self.onglets.vue_changee.connect(self._activer)
1286
+ self.vue_galerie.case_ouverte.connect(self.ouvrir_la_frame)
1287
+
1288
+ @property
1289
+ def vues(self) -> dict:
1290
+ return {
1291
+ _barre.ONGLET_PAGE: self.vue_page,
1292
+ _barre.ONGLET_GALERIE: self.vue_galerie,
1293
+ _barre.ONGLET_FRAME: self.vue_frame,
1294
+ }
1295
+
1296
+ def poser_document(self, document, page_visee=None) -> None:
1297
+ """Poser un document de detection. **Rien n'est ecrit nulle part.**"""
1298
+ self.document = document
1299
+ self.vue_galerie.poser(document)
1300
+ page = page_visee if page_visee is not None else document.pages[0]
1301
+ self.vue_page.poser(page)
1302
+ # Changer de lot referme la vue vignette unique : son objet a disparu,
1303
+ # donc son onglet aussi -- absent, jamais grise.
1304
+ self.vue_frame.adresse = None
1305
+ self.onglets.poser_onglets((_barre.ONGLET_PAGE, _barre.ONGLET_GALERIE))
1306
+ self._activer(self.onglets.vue_courante())
1307
+
1308
+ def poser_fournisseur(self, fournisseur) -> None:
1309
+ for vue in self.vues.values():
1310
+ vue.poser_fournisseur(fournisseur)
1311
+
1312
+ def ouvrir_la_frame(self, adresse) -> None:
1313
+ """Ouvrir la vue vignette unique sur cette frame, et lui donner son onglet."""
1314
+ read_rank, page_index, slot_index = adresse
1315
+ page = self.document.page_par_adresse(read_rank, page_index)
1316
+ self.vue_frame.poser(page, page.zone(slot_index))
1317
+ self.onglets.poser_onglets(
1318
+ (_barre.ONGLET_PAGE, _barre.ONGLET_GALERIE, _barre.ONGLET_FRAME)
1319
+ )
1320
+ self.onglets.activer(_barre.ONGLET_FRAME)
1321
+
1322
+ def _activer(self, cle) -> None:
1323
+ if cle is None:
1324
+ return
1325
+ self.pile.setCurrentWidget(self.vues[cle])
1326
+
1327
+
1328
+ __all__ = [
1329
+ "AtelierScanJugement",
1330
+ "BandeauDEtatDePage",
1331
+ "BandeauDeVerrous",
1332
+ "CASE_ABSENTE",
1333
+ "CASE_MIRE",
1334
+ "CASE_PRESENTE",
1335
+ "CaseDEmplacement",
1336
+ "CaseDePageManquante",
1337
+ "IndicateurDeVerrou",
1338
+ "JETON_COULEUR_PAR_CASE",
1339
+ "JETON_COULEUR_PAR_VERROU",
1340
+ "LegendeDeZone",
1341
+ "LigneDePageDeGalerie",
1342
+ "PanneauLateralDeVue",
1343
+ "SurfaceDImage",
1344
+ "VueGalerie",
1345
+ "VueModePdf",
1346
+ "VueVignetteUnique",
1347
+ "badge_de_completude_des_planches",
1348
+ "etat_de_case",
1349
+ "planches_manquantes",
1350
+ "rangs_et_trous",
1351
+ ]