GUI documentation
This commit is contained in:
+88
-21
@@ -60,6 +60,65 @@ ANNOTATION_VARIANT_DIRECTORIES = {
|
||||
}
|
||||
|
||||
|
||||
class Tooltip:
|
||||
"""Small delayed tooltip for Tk and ttk widgets."""
|
||||
|
||||
def __init__(self, widget: tk.Widget, text: str, delay_ms: int = 450) -> None:
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.delay_ms = delay_ms
|
||||
self._after_id: str | None = None
|
||||
self.window: tk.Toplevel | None = None
|
||||
widget.bind("<Enter>", self._schedule, add="+")
|
||||
widget.bind("<Leave>", self.hide, add="+")
|
||||
widget.bind("<ButtonPress>", self.hide, add="+")
|
||||
|
||||
def _schedule(self, _event: tk.Event | None = None) -> None:
|
||||
self.hide()
|
||||
self._after_id = self.widget.after(self.delay_ms, self.show)
|
||||
|
||||
def show(self) -> None:
|
||||
self._after_id = None
|
||||
if self.window is not None or not self.widget.winfo_exists():
|
||||
return
|
||||
self.window = tk.Toplevel(self.widget)
|
||||
self.window.wm_overrideredirect(True)
|
||||
self.window.attributes("-topmost", True)
|
||||
x = self.widget.winfo_rootx() + 12
|
||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6
|
||||
self.window.wm_geometry(f"+{x}+{y}")
|
||||
tk.Label(
|
||||
self.window,
|
||||
text=self.text,
|
||||
justify="left",
|
||||
wraplength=430,
|
||||
background="#fffbd8",
|
||||
foreground="#202020",
|
||||
relief="solid",
|
||||
borderwidth=1,
|
||||
padx=8,
|
||||
pady=5,
|
||||
).pack()
|
||||
|
||||
def hide(self, _event: tk.Event | None = None) -> None:
|
||||
if self._after_id is not None:
|
||||
try:
|
||||
self.widget.after_cancel(self._after_id)
|
||||
except tk.TclError:
|
||||
pass
|
||||
self._after_id = None
|
||||
if self.window is not None:
|
||||
self.window.destroy()
|
||||
self.window = None
|
||||
|
||||
|
||||
def attach_tooltip(widget: tk.Widget, text: str) -> Tooltip:
|
||||
tooltip = Tooltip(widget, text)
|
||||
# Keep the tooltip easy to inspect and alive for as long as its widget.
|
||||
widget._copienator_tooltip = tooltip # type: ignore[attr-defined]
|
||||
return tooltip
|
||||
|
||||
|
||||
def copy_pdf_paths(evaluation: Path) -> list[Path]:
|
||||
"""List the most relevant version of each scanned copy."""
|
||||
locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales")
|
||||
@@ -231,19 +290,33 @@ class CopienatorApp(tk.Tk):
|
||||
)
|
||||
environment = ttk.Frame(top)
|
||||
environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0))
|
||||
ttk.Checkbutton(
|
||||
proxy_toggle = ttk.Checkbutton(
|
||||
environment,
|
||||
text="Utiliser le proxy HTTPS",
|
||||
variable=self.use_proxy_var,
|
||||
command=self._toggle_proxy,
|
||||
).pack(side="left", padx=(0, 6))
|
||||
)
|
||||
proxy_toggle.pack(side="left", padx=(0, 6))
|
||||
attach_tooltip(
|
||||
proxy_toggle,
|
||||
"Active le proxy HTTPS configuré dans le champ voisin pour les commandes lancées par le GUI.",
|
||||
)
|
||||
self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled")
|
||||
self.proxy_entry.pack(side="left")
|
||||
ttk.Checkbutton(
|
||||
attach_tooltip(
|
||||
self.proxy_entry,
|
||||
"Adresse du proxy HTTPS transmise aux commandes lorsque l’option de proxy est activée.",
|
||||
)
|
||||
verbose_toggle = ttk.Checkbutton(
|
||||
environment,
|
||||
text="Afficher les détails en cas d’erreur (--verbose)",
|
||||
text="Détails d’erreur ⓘ",
|
||||
variable=self.verbose_var,
|
||||
).pack(side="left", padx=(10, 0))
|
||||
)
|
||||
verbose_toggle.pack(side="left", padx=(10, 0))
|
||||
attach_tooltip(
|
||||
verbose_toggle,
|
||||
"Ajoute --verbose aux commandes compatibles afin d’afficher la trace complète lorsqu’une erreur inattendue survient.",
|
||||
)
|
||||
profile = "standard + personnel" if show_personal_steps else "standard"
|
||||
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
|
||||
|
||||
@@ -647,7 +720,9 @@ class CopienatorApp(tk.Tk):
|
||||
choices = spec.choices
|
||||
if spec.name == "annotation_dir" and step.id in {"export", "import"}:
|
||||
choices, value = self._annotation_directory_choices(step.id)
|
||||
ttk.Label(self.form, text=spec.label).grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8))
|
||||
argument_label = ttk.Label(self.form, text=f"{spec.label} ⓘ")
|
||||
argument_label.grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8))
|
||||
attach_tooltip(argument_label, spec.help)
|
||||
if spec.kind == "bool":
|
||||
variable: tk.Variable = tk.BooleanVar(value=bool(value))
|
||||
widget = ttk.Checkbutton(self.form, variable=variable)
|
||||
@@ -672,11 +747,7 @@ class CopienatorApp(tk.Tk):
|
||||
)
|
||||
self.arg_vars[spec.name] = variable
|
||||
variable.trace_add("write", lambda *_args: self._update_command_preview())
|
||||
if spec.help:
|
||||
ttk.Label(self.form, text=spec.help, foreground="#666666", wraplength=540).grid(
|
||||
row=row + 1, column=1, sticky="w"
|
||||
)
|
||||
row += 1
|
||||
attach_tooltip(widget, spec.help)
|
||||
row += 1
|
||||
|
||||
show_extra = bool(step.extra_arguments_help) and (
|
||||
@@ -684,18 +755,14 @@ class CopienatorApp(tk.Tk):
|
||||
or variant.id in step.extra_arguments_variants
|
||||
)
|
||||
if show_extra and not step.is_manual and step.section != REFAIRE_SECTION:
|
||||
ttk.Label(self.form, text="Arguments supplémentaires").grid(
|
||||
extra_label = ttk.Label(self.form, text="Arguments supplémentaires ⓘ")
|
||||
extra_label.grid(
|
||||
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
|
||||
)
|
||||
ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4))
|
||||
row += 1
|
||||
ttk.Label(
|
||||
self.form,
|
||||
text=step.extra_arguments_help,
|
||||
foreground="#666666",
|
||||
wraplength=540,
|
||||
justify="left",
|
||||
).grid(row=row, column=1, sticky="w")
|
||||
extra_entry = ttk.Entry(self.form, textvariable=self.extra_var)
|
||||
extra_entry.grid(row=row, column=1, sticky="ew", pady=(10, 4))
|
||||
attach_tooltip(extra_label, step.extra_arguments_help)
|
||||
attach_tooltip(extra_entry, step.extra_arguments_help)
|
||||
row += 1
|
||||
|
||||
if self.show_personal_steps and step.id == "statement":
|
||||
|
||||
+148
-39
@@ -61,14 +61,14 @@ class StepDefinition:
|
||||
return all(variant.kind == "manual" for variant in self.variants)
|
||||
|
||||
|
||||
def arg_target(help_text: str = "Dossier d’évaluation ou fichier à traiter") -> ArgumentSpec:
|
||||
def arg_target(help_text: str = "un dossier d’évaluation ou un fichier à traiter") -> ArgumentSpec:
|
||||
return ArgumentSpec(
|
||||
"target",
|
||||
"Cible",
|
||||
kind="path",
|
||||
default=EVALUATION,
|
||||
positional=True,
|
||||
help=help_text,
|
||||
help=f"La cible peut être {help_text.rstrip('.')}.",
|
||||
)
|
||||
|
||||
|
||||
@@ -107,12 +107,13 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
) + ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
||||
if show_personal_steps else ()),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"restart",
|
||||
"Ignorer le cache (--restart)",
|
||||
kind="bool",
|
||||
flag="--restart",
|
||||
help="Ignore les résultats Gemini mis en cache et recommence entièrement l’analyse de l’énoncé.",
|
||||
variants=("gemini",),
|
||||
),
|
||||
),
|
||||
@@ -124,7 +125,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Facultatif après la génération : remplace les groupes par exercice par des groupes "
|
||||
"proposés par Gemini, en conservant les labels, les énoncés, les solutions et les barèmes.",
|
||||
(python("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -132,7 +133,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Facultatif : remplace Persp par des barèmes Gemini sur 4 points, pour les groupes actuels. "
|
||||
"Les énoncés et les solutions personnels sont conservés.",
|
||||
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -160,7 +161,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
supports_verbose=True,
|
||||
),
|
||||
),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -179,7 +180,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
supports_verbose=True,
|
||||
),
|
||||
),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
),
|
||||
StepDefinition(
|
||||
"page_splitter",
|
||||
@@ -187,7 +188,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Séparer et réordonner les pages",
|
||||
"Ouvre l’outil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.",
|
||||
(python("default", "Séparation des pages", "page-split"),),
|
||||
arguments=(arg_target(), ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked")),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec(
|
||||
"marked",
|
||||
"Copies signalées uniquement",
|
||||
"bool",
|
||||
"--marked",
|
||||
help="Limite l’opération aux copies signalées dans l’interface au lieu de traiter toutes les copies.",
|
||||
),
|
||||
),
|
||||
artifacts=("Copies", "Copies Originales"),
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -198,8 +208,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"puis remplace les PDF dans Copies. Les versions non rognées sont sauvegardées. "
|
||||
"À effectuer avant la détection des labels. Traite plusieurs copies en parallèle.",
|
||||
(python("default", "Rognage des zones vides", "crop-margins"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation ou PDF dans Copies"),
|
||||
ArgumentSpec("workers", "Copies traitées en parallèle", "int", "--workers", default=5)),
|
||||
arguments=(
|
||||
arg_target("le dossier de l’évaluation ou un PDF du dossier Copies"),
|
||||
ArgumentSpec(
|
||||
"workers",
|
||||
"Copies traitées en parallèle",
|
||||
"int",
|
||||
"--workers",
|
||||
default=5,
|
||||
help="Fixe le nombre maximal de copies rognées simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
requires=("Copies",),
|
||||
auto_start_first_visit=ALWAYS_CROP,
|
||||
@@ -212,8 +231,20 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
(python("default", "Découpe", "crop-labels"),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
|
||||
ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked"),
|
||||
ArgumentSpec(
|
||||
"fullpage",
|
||||
"Toujours utiliser la page entière",
|
||||
"bool",
|
||||
"--fullpage",
|
||||
help="Désactive la découpe habituelle de la marge gauche et transmet chaque page entière à la détection des labels.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"marked",
|
||||
"Copies signalées uniquement",
|
||||
"bool",
|
||||
"--marked",
|
||||
help="Limite l’opération aux copies signalées dans l’interface au lieu de traiter toutes les copies.",
|
||||
),
|
||||
),
|
||||
requires=("Copies",),
|
||||
artifacts=("Cutleft",),
|
||||
@@ -226,7 +257,13 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
(python("default", "Détection des labels", "labels"),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"),
|
||||
ArgumentSpec(
|
||||
"overwrite",
|
||||
"Régénérer les résultats",
|
||||
"bool",
|
||||
"--overwrite",
|
||||
help="Relance la détection même lorsqu’un fichier JSON de labels existe déjà pour la copie.",
|
||||
),
|
||||
),
|
||||
requires=("labels", "Copies", "Cutleft"),
|
||||
artifacts=("Copies/*.json",),
|
||||
@@ -265,13 +302,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"sauvegardés et plusieurs fichiers sont analysés en parallèle.",
|
||||
(python("default", "Rognage du bas", "crop-answer-bottoms"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"workers",
|
||||
"PDF traités en parallèle",
|
||||
"int",
|
||||
"--workers",
|
||||
default=5,
|
||||
help="Fixe le nombre maximal de PDF de réponse analysés simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
@@ -284,7 +322,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Regrouper les réponses",
|
||||
"Regroupe les réponses portant le même label pour préparer les requêtes.",
|
||||
(python("default", "Regroupement", "group-answers"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
requires=("Copies",),
|
||||
artifacts=("Par label",),
|
||||
auto_start_first_visit=True,
|
||||
@@ -308,12 +346,13 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
|
||||
),
|
||||
arguments=(
|
||||
arg_target("Évaluation ou image Group_X.jpg"),
|
||||
arg_target("le dossier de l’évaluation ou une image Group_X.jpg"),
|
||||
ArgumentSpec(
|
||||
"overwrite",
|
||||
"Écraser les corrections existantes",
|
||||
"bool",
|
||||
"--overwrite",
|
||||
help="Relance les corrections demandées même lorsqu’un résultat existe déjà.",
|
||||
variants=("live", "batch", "hybrid", "refaire"),
|
||||
),
|
||||
ArgumentSpec(
|
||||
@@ -321,9 +360,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Limite d’appels Pro",
|
||||
"int",
|
||||
"--limit",
|
||||
help="Limite le nombre d’appels au modèle Pro pendant cette exécution. Laissez ce champ vide pour ne pas imposer de limite.",
|
||||
variants=("live", "hybrid", "refaire"),
|
||||
),
|
||||
ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)),
|
||||
ArgumentSpec(
|
||||
"batch_from",
|
||||
"Premier label envoyé en batch",
|
||||
"text",
|
||||
"--batch-from",
|
||||
help="Indique le premier label traité en batch ; les labels précédents sont corrigés immédiatement.",
|
||||
variants=("hybrid",),
|
||||
),
|
||||
),
|
||||
requires=("Par label", "Persp", "labels"),
|
||||
artifacts=("correction.json", "batch_requests_*.jsonl"),
|
||||
@@ -339,7 +386,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Envoyer les batchs",
|
||||
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
|
||||
(python("default", "Envoi", "batch-submit"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
artifacts=("batch_jobs.json",),
|
||||
skip_for_live_correction=True,
|
||||
@@ -351,13 +398,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Affiche les jobs Gemini en cours. L’identifiant de téléchargement est facultatif.",
|
||||
(python("default", "État des batchs", "batch-status"),),
|
||||
arguments=(
|
||||
ArgumentSpec("download", "Télécharger le job", "text", "--download"),
|
||||
ArgumentSpec(
|
||||
"download",
|
||||
"Télécharger le job",
|
||||
"text",
|
||||
"--download",
|
||||
help="Saisissez l’identifiant complet d’un job Gemini terminé pour télécharger son fichier de résultats.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"output",
|
||||
"Fichier JSONL de destination",
|
||||
"path",
|
||||
"--output",
|
||||
help="Utilisé avec un identifiant de téléchargement.",
|
||||
help="Choisissez le fichier JSONL dans lequel enregistrer le job téléchargé. Ce champ n’est utilisé que si un identifiant de job est fourni.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
@@ -369,7 +422,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Récupérer les résultats batch",
|
||||
"Télécharge et rassemble les réponses des jobs terminés.",
|
||||
(python("default", "Récupération", "batch-fetch"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
skip_for_live_correction=True,
|
||||
),
|
||||
@@ -379,7 +432,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Nettoyer la correction",
|
||||
"Corrige certains problèmes d’encodage et prépare le texte pour LaTeX.",
|
||||
(python("default", "Post-correction", "post-correction"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
requires=("correction.json",),
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -388,7 +441,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Résoudre les conflits manuels",
|
||||
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.",
|
||||
(python("default", "Résolution", "resolve-manual"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
requires=("manual_resolutions.txt", "correction.json"),
|
||||
skip_without_manual_conflicts=True,
|
||||
@@ -404,9 +457,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
|
||||
),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks", "grouped")),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"overwrite",
|
||||
"Écraser les sorties",
|
||||
"bool",
|
||||
"--overwrite",
|
||||
help="Remplace les annotations déjà générées dans le dossier de sortie sélectionné.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Génère uniquement les copies et questions inscrites dans refaire.json, dans le dossier réservé à la reprise.",
|
||||
variants=("checks", "grouped"),
|
||||
),
|
||||
),
|
||||
requires=("correction.json",),
|
||||
artifacts=("Anot", "Bnot", "BGnot"),
|
||||
@@ -418,16 +484,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
|
||||
(python("default", "Export", "export"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Dossier d’annotations",
|
||||
"choice",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Choisissez le dossier d’annotations à exporter : groupées, avec cases, ou simples.",
|
||||
positional=True,
|
||||
),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Exporte les annotations du passage de reprise BRnot au lieu du dossier principal.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
),
|
||||
@@ -445,16 +518,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
|
||||
(python("default", "Import", "import"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Dossier d’annotations",
|
||||
"choice",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Choisissez le dossier principal dans lequel importer les annotations manuscrites.",
|
||||
positional=True,
|
||||
),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Importe les annotations manuscrites dans BRnot pour le passage de reprise.",
|
||||
),
|
||||
),
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -467,9 +547,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
python("grouped", "Lecture BGnot", "read-grouped"),
|
||||
),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"update_score",
|
||||
"Réappliquer les score.json",
|
||||
"bool",
|
||||
"--update-score",
|
||||
help="Réutilise les valeurs présentes dans les fichiers score.json pour remplacer les scores lus dans les annotations.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Lit les annotations du passage de reprise BRnot et les fusionne avec les copies principales.",
|
||||
variants=("grouped",),
|
||||
),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Passage principal du mode refaire",
|
||||
@@ -477,6 +570,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"--annotation-dir",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Indique le dossier d’annotations du passage principal dans lequel intégrer les questions refaites.",
|
||||
variants=("grouped",),
|
||||
),
|
||||
),
|
||||
@@ -488,13 +582,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Crée le dossier A Rendre à partir du dossier d’annotations choisi.",
|
||||
(python("default", "Attribution des noms", "giving-names"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Dossier d’annotations",
|
||||
"choice",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Choisissez le dossier d’annotations utilisé pour construire les fichiers nommés dans A Rendre.",
|
||||
positional=True,
|
||||
),
|
||||
),
|
||||
@@ -523,8 +618,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
|
||||
(python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"sum",
|
||||
"Écrire seulement la somme",
|
||||
"bool",
|
||||
"--sum",
|
||||
help="Écrit uniquement la note totale de chaque élève dans le fichier ODS, sans détailler les scores par question.",
|
||||
),
|
||||
),
|
||||
personal=True,
|
||||
),
|
||||
@@ -550,7 +651,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Ajouter le score final",
|
||||
"Génère les fichiers de diffusion avec le score final.",
|
||||
(python("default", "Score final", "add-final-score", supports_verbose=False),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
personal=True,
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -602,7 +703,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
fixed_args=("--dry-run",),
|
||||
),
|
||||
),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
requires=("Copies", "correction.json", "A Rendre"),
|
||||
),
|
||||
@@ -635,7 +736,15 @@ def build_refaire_workflow() -> list[StepDefinition]:
|
||||
for suffix, title, description, program, flags, optional in definitions:
|
||||
arguments = (arg_target(),) if program else ()
|
||||
if suffix == "merge":
|
||||
arguments += (ArgumentSpec("annotation_dir", "Passage principal", "choice", "--annotation-dir", default="BGnot", choices=("BGnot", "Bnot", "Anot")),)
|
||||
arguments += (ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Passage principal",
|
||||
"choice",
|
||||
"--annotation-dir",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Indique le dossier d’annotations du passage principal dans lequel intégrer les questions refaites.",
|
||||
),)
|
||||
requirements = ("refaire.json", "Copies", "labels", "correction.json")
|
||||
if suffix in {"export", "tablet", "import", "merge"}:
|
||||
requirements += ("BRnot",)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Scanned PDF margin cropping
|
||||
|
||||
Copienator has one automatic crop detector. It finds strongly coloured or dark
|
||||
ink, recovers nearby weaker strokes, and removes substantial blank areas above
|
||||
and below the detected content. It is designed for scanned student work on
|
||||
plain, lined, or gridded paper, including mildly skewed pages and recurring
|
||||
punched-hole artifacts.
|
||||
|
||||
## Review utility
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python -m copienator.crop_blank_margins Interro01/Copies tmp/cropped-copies
|
||||
```
|
||||
|
||||
The input can be a directory or one PDF. Directory processing includes only
|
||||
PDFs directly inside that directory. The utility writes processed PDFs, an HTML
|
||||
comparison gallery, JPEG previews, and JSON/CSV reports into the output
|
||||
directory. Source PDFs are never modified.
|
||||
|
||||
Available options:
|
||||
|
||||
- `--dpi 200`: analysis resolution.
|
||||
- `--padding-mm 6`: space retained around detected content.
|
||||
- `--min-crop-mm 5`: minimum worthwhile removal at either edge.
|
||||
|
||||
The output retains filenames, page order, page count, colour, rotation, and the
|
||||
embedded scan data. Cropping changes the PDF CropBox rather than rasterizing the
|
||||
page. Red shading in `index.html` shows the removed part of each original page.
|
||||
A `review-*` status records uncertainty; one edge can still be cropped while the
|
||||
other remains unchanged.
|
||||
|
||||
## Detection
|
||||
|
||||
The detector uses colour and darkness as strong ink seeds. It recovers connected
|
||||
weak strokes within a 2 mm neighbourhood using directional contrast, which
|
||||
limits growth along paper lines. Two seed thresholds are compared so unstable
|
||||
boundaries can be flagged for review.
|
||||
|
||||
For dark neutral paper, it deskews the scan and confirms repeated horizontal or
|
||||
vertical ruling before suppressing paper-line pixels. It uses short directional
|
||||
openings to tolerate broken or bent grid lines. Very dark fraction bars and
|
||||
diagram axes remain protected. Repeated components with similar size and
|
||||
alignment in the outer 15 mm are treated as punched holes only when at least
|
||||
three span a substantial part of the page. Writing in the same side column still
|
||||
protects its margin.
|
||||
|
||||
The large-blank refinement changes an edge only when it finds at least 30 mm of
|
||||
additional empty paper. A 2 mm recovery neighbourhood is applied before the
|
||||
normal padding. Apparently blank pages and pages without reliable ink seeds are
|
||||
kept at full height.
|
||||
|
||||
This remains a heuristic. Extremely faint isolated pencil marks, unusually
|
||||
damaged ruling, and repeated handwriting shaped like hole artifacts can be
|
||||
ambiguous. Review crops before generating answer coordinates.
|
||||
|
||||
## Optional GUI step
|
||||
|
||||
After **Séparer et réordonner les pages**, the GUI offers **Rogner les zones
|
||||
vides**. It can process the whole evaluation or a selected PDF. It runs at 200
|
||||
dpi with 6 mm padding and uses five worker processes by default. The CLI form is:
|
||||
|
||||
```sh
|
||||
python -m copienator crop-margins EVALUATION --workers 5
|
||||
```
|
||||
|
||||
The batch is fully prepared before any source is replaced. Detection failures
|
||||
and interruptions leave the working PDFs intact; replacement errors roll back.
|
||||
Each successful run saves the untrimmed PDFs and its report under
|
||||
`.copienator/runs/crop-margins-*/`. The **Archivage** step removes these backups
|
||||
and reports while keeping the cropped copies and execution logs.
|
||||
|
||||
Cropping must run before label detection. If a selected PDF already has a
|
||||
same-named JSON coordinate file, the command stops before changing any PDFs.
|
||||
When `ALWAYS_CROP` is true in `config.py`, this facultative step starts
|
||||
automatically when first reached; the default configuration keeps it manual.
|
||||
|
||||
## Performance
|
||||
|
||||
Separate worker processes isolate MuPDF and each worker uses one OpenCV thread.
|
||||
The detector uses native channel operations, vectorized component filtering,
|
||||
cached separable background filtering, and a coarser Hough voting step for skew
|
||||
candidates. Report rows remain ordered by copy and page regardless of worker
|
||||
completion order.
|
||||
|
||||
On the Ryzen 7 PRO 7840U, an end-to-end benchmark took 56.48 seconds for 48 PDFs
|
||||
of 10 pages, including rendering, detection, PDF writing, backups, and
|
||||
replacement. The fixture uses Interro01 and DS08VA scans and cycles pages in
|
||||
shorter copies, so it does not contain 480 distinct scans. Runtime depends on the
|
||||
CPU, storage, and scan content.
|
||||
|
||||
Run the focused checks with:
|
||||
|
||||
```sh
|
||||
python -m unittest tests.test_crop_blank_margins tests.test_ink_detection tests.test_crop_margins_command -v
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# Bottom cropping after exercise splitting
|
||||
|
||||
This review utility processes the exercise PDFs stored directly under
|
||||
`Copies/CopieXX/`. It never changes the source files. Modified PDFs are written
|
||||
to a matching tree under the chosen output directory; unchanged PDFs are not
|
||||
copied.
|
||||
|
||||
```sh
|
||||
python -m copienator.crop_exercise_bottoms Interro01 tmp/exercise-bottom-crop
|
||||
```
|
||||
|
||||
One line is 1/36 of the uncropped full-page height recorded by the matching
|
||||
`Copies/CopieXX.pdf`. Each exercise PDF page is considered independently:
|
||||
|
||||
1. Pages shorter than 10 lines are skipped.
|
||||
2. The bottom 0.75 line is excluded from detection so a fragment of the next
|
||||
label cannot keep a large blank area.
|
||||
3. The existing scan detector locates the last ink above that strip and keeps
|
||||
6 mm of padding.
|
||||
4. The bottom CropBox changes only if the proposed removal is at least 4 lines.
|
||||
The top CropBox is always retained.
|
||||
|
||||
The ignored 0.75-line strip is therefore not removed on its own. It is included
|
||||
in the result only when the complete proposed crop passes the four-line
|
||||
threshold.
|
||||
|
||||
The output contains `index.html`, previews with removed areas shaded red, a
|
||||
plain `cropped-files.txt` list, `report.json`, and `report.csv`. The command uses
|
||||
five worker processes by default; `--workers`, `--dpi`, and `--padding-mm` are
|
||||
configurable.
|
||||
|
||||
## GUI integration
|
||||
|
||||
After **Découper les réponses par question**, the GUI offers the facultative
|
||||
step **Rogner le bas des réponses**. It runs the same thresholds at 200 dpi and
|
||||
uses five worker processes by default. Only PDFs with an accepted crop are
|
||||
replaced. Their unmodified versions and the complete report are stored under
|
||||
`.copienator/runs/crop-exercise-bottoms-*/`; a failure or interruption before
|
||||
publication leaves every exercise PDF unchanged. The **Archivage** step removes
|
||||
these retained originals and reports. When `ALWAYS_CROP` is true in `config.py`,
|
||||
this facultative step starts automatically when first reached; the default
|
||||
configuration keeps it manual.
|
||||
@@ -153,12 +153,45 @@ class GuiConvenienceTests(unittest.TestCase):
|
||||
self.app.tree.selection_set("labels")
|
||||
self.app.update()
|
||||
labels_text = self._label_texts(self.app.form)
|
||||
self.assertIn("Arguments supplémentaires", labels_text)
|
||||
self.assertTrue(any("Cutleft" in text for text in labels_text))
|
||||
extra_label = next(
|
||||
child
|
||||
for child in self.app.form.winfo_children()
|
||||
if isinstance(child, ttk.Label)
|
||||
and str(child.cget("text")).startswith("Arguments supplémentaires")
|
||||
)
|
||||
tooltip = extra_label._copienator_tooltip
|
||||
self.assertIn("Cutleft", tooltip.text)
|
||||
self.assertFalse(any("Cutleft" in text for text in labels_text))
|
||||
|
||||
tooltip.show()
|
||||
self.app.update()
|
||||
self.assertIsNotNone(tooltip.window)
|
||||
self.assertIn("Cutleft", tooltip.window.winfo_children()[0].cget("text"))
|
||||
tooltip.hide()
|
||||
|
||||
self.app.tree.selection_set("plotting")
|
||||
self.app.update()
|
||||
self.assertNotIn("Arguments supplémentaires", self._label_texts(self.app.form))
|
||||
self.assertFalse(
|
||||
any(
|
||||
text.startswith("Arguments supplémentaires")
|
||||
for text in self._label_texts(self.app.form)
|
||||
)
|
||||
)
|
||||
|
||||
def test_each_visible_argument_label_has_a_tooltip(self):
|
||||
self.app.tree.selection_set("correction")
|
||||
self.app.update()
|
||||
argument_labels = [
|
||||
child
|
||||
for child in self.app.form.winfo_children()
|
||||
if isinstance(child, ttk.Label) and str(child.cget("text")).endswith("ⓘ")
|
||||
]
|
||||
self.assertGreaterEqual(len(argument_labels), 4)
|
||||
for label in argument_labels:
|
||||
with self.subTest(label=label.cget("text")):
|
||||
tooltip = getattr(label, "_copienator_tooltip", None)
|
||||
self.assertIsNotNone(tooltip)
|
||||
self.assertTrue(tooltip.text.strip())
|
||||
|
||||
def test_verbose_checkbox_updates_supported_commands(self):
|
||||
self.app.tree.selection_set("labels")
|
||||
|
||||
@@ -1829,6 +1829,13 @@ class WorkflowTests(unittest.TestCase):
|
||||
("live", "batch", "hybrid", "refaire"),
|
||||
)
|
||||
|
||||
def test_every_graphical_argument_has_tooltip_documentation(self) -> None:
|
||||
for step in self.steps.values():
|
||||
for spec in step.arguments:
|
||||
with self.subTest(step=step.id, argument=spec.name):
|
||||
self.assertTrue(spec.help.strip())
|
||||
self.assertTrue(spec.help.rstrip().endswith("."))
|
||||
|
||||
def test_options_previously_requiring_free_form_arguments_have_controls(self) -> None:
|
||||
status = self.command(
|
||||
"batch_status",
|
||||
|
||||
Reference in New Issue
Block a user