GUI documentation

This commit is contained in:
2026-09-10 07:23:39 +02:00
parent f7b3689f23
commit dfce79f240
6 changed files with 418 additions and 63 deletions
+88 -21
View File
@@ -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 loption de proxy est activée.",
)
verbose_toggle = ttk.Checkbutton(
environment,
text="Afficher les détails en cas derreur (--verbose)",
text="Détails derreur ⓘ",
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 dafficher la trace complète lorsquune 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
View File
@@ -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 lanalyse 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 loutil 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 lopération aux copies signalées dans linterface 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 lopération aux copies signalées dans linterface 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 lorsquun 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 lorsquun 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 dappels Pro",
"int",
"--limit",
help="Limite le nombre dappels 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. Lidentifiant 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 lidentifiant complet dun 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 nest 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 dencodage 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 dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier dannotations à 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 dannotations",
"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 dannotations 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 dannotations 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 dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier dannotations 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 dannotations 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",)
+97
View File
@@ -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
```
+42
View File
@@ -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.
+36 -3
View File
@@ -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")
+7
View File
@@ -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",