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.
- mixed_media_utility/__init__.py +1 -0
- mixed_media_utility/cadence_previz.py +1997 -0
- mixed_media_utility/cli.py +5325 -0
- mixed_media_utility/codec_profiles.py +2373 -0
- mixed_media_utility/color_calibration.py +4303 -0
- mixed_media_utility/color_metrics.py +1314 -0
- mixed_media_utility/color_pipeline.py +388 -0
- mixed_media_utility/constants.py +6 -0
- mixed_media_utility/declaration_de_rush.py +1335 -0
- mixed_media_utility/detection/__init__.py +35 -0
- mixed_media_utility/detection/aruco.py +463 -0
- mixed_media_utility/encode.py +3204 -0
- mixed_media_utility/encode_master.py +724 -0
- mixed_media_utility/encode_previz.py +1976 -0
- mixed_media_utility/extraction.py +1723 -0
- mixed_media_utility/extraction_previz.py +1002 -0
- mixed_media_utility/ffmpeg_utils.py +629 -0
- mixed_media_utility/frame_selection.py +994 -0
- mixed_media_utility/gamut_map.py +393 -0
- mixed_media_utility/gui/__init__.py +6 -0
- mixed_media_utility/gui/__main__.py +102 -0
- mixed_media_utility/gui/application.py +125 -0
- mixed_media_utility/gui/atelier_scan.py +639 -0
- mixed_media_utility/gui/barre_de_vue.py +310 -0
- mixed_media_utility/gui/cartes_taches.py +214 -0
- mixed_media_utility/gui/catalogue.py +399 -0
- mixed_media_utility/gui/chargeur_detections.py +119 -0
- mixed_media_utility/gui/chutier.py +1153 -0
- mixed_media_utility/gui/coquille.py +1384 -0
- mixed_media_utility/gui/depot_projets.py +266 -0
- mixed_media_utility/gui/ecran_projet.py +958 -0
- mixed_media_utility/gui/executeur.py +193 -0
- mixed_media_utility/gui/feuille_application.py +257 -0
- mixed_media_utility/gui/jetons.py +432 -0
- mixed_media_utility/gui/lecture_detection.py +431 -0
- mixed_media_utility/gui/modele_chutier.py +680 -0
- mixed_media_utility/gui/modele_projets.py +279 -0
- mixed_media_utility/gui/modele_zone_tampon.py +478 -0
- mixed_media_utility/gui/preferences_projets.py +98 -0
- mixed_media_utility/gui/raster_de_page.py +153 -0
- mixed_media_utility/gui/scan_jugement.py +1351 -0
- mixed_media_utility/gui/surimpressions.py +520 -0
- mixed_media_utility/gui/zone_tampon.py +587 -0
- mixed_media_utility/io/__init__.py +67 -0
- mixed_media_utility/io/calibration_profile.py +1316 -0
- mixed_media_utility/io/encode_manifest.py +940 -0
- mixed_media_utility/io/extraction_manifest.py +2018 -0
- mixed_media_utility/io/manifest.py +319 -0
- mixed_media_utility/io/metadata_matrix.py +237 -0
- mixed_media_utility/io/naming.py +1516 -0
- mixed_media_utility/io/payload.py +1334 -0
- mixed_media_utility/io/pdf_manifest.py +483 -0
- mixed_media_utility/io/profile_designation.py +478 -0
- mixed_media_utility/io/project_layout.py +660 -0
- mixed_media_utility/io/reconstruction.py +1120 -0
- mixed_media_utility/io/scan_manifest.py +2216 -0
- mixed_media_utility/io/version_ranks.py +164 -0
- mixed_media_utility/layout.py +313 -0
- mixed_media_utility/makepdf.py +1097 -0
- mixed_media_utility/numeric_guards.py +44 -0
- mixed_media_utility/page_payload.py +360 -0
- mixed_media_utility/page_roles.py +149 -0
- mixed_media_utility/page_templates.py +2701 -0
- mixed_media_utility/patch_presets.py +1599 -0
- mixed_media_utility/patch_values.py +1018 -0
- mixed_media_utility/pdf_composition.py +2763 -0
- mixed_media_utility/pdf_previz.py +1017 -0
- mixed_media_utility/pdf_render.py +417 -0
- mixed_media_utility/previz_common.py +361 -0
- mixed_media_utility/progression.py +340 -0
- mixed_media_utility/project_inventory.py +1213 -0
- mixed_media_utility/project_maintenance.py +2995 -0
- mixed_media_utility/qr_codes.py +920 -0
- mixed_media_utility/relink.py +634 -0
- mixed_media_utility/scan_calibrate.py +855 -0
- mixed_media_utility/scan_chain.py +240 -0
- mixed_media_utility/scan_corrections.py +1166 -0
- mixed_media_utility/scan_crop.py +481 -0
- mixed_media_utility/scan_detect.py +1395 -0
- mixed_media_utility/scan_detection.py +1432 -0
- mixed_media_utility/scan_ingest.py +1974 -0
- mixed_media_utility/scan_output_frames.py +2238 -0
- mixed_media_utility/scan_previz.py +3113 -0
- mixed_media_utility/scan_sorting.py +825 -0
- mixed_media_utility/scan_write.py +2298 -0
- mixed_media_utility/source_confirmation.py +1225 -0
- mixed_media_utility/specs/project.schema.json +999 -0
- mixed_media_utility/specs/project.schema.legacy.json +52 -0
- mixed_media_utility/specs/project.schema.v2-0.json +323 -0
- mixed_media_utility/video_metadata.py +416 -0
- mmu_cli-0.1.0.dist-info/METADATA +217 -0
- mmu_cli-0.1.0.dist-info/RECORD +96 -0
- mmu_cli-0.1.0.dist-info/WHEEL +4 -0
- mmu_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mmu_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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
|
+
]
|