diff --git a/copienator_gui/app.py b/copienator_gui/app.py index cc1d147..7cda0a8 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -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("", self._schedule, add="+") + widget.bind("", self.hide, add="+") + widget.bind("", 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": diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 3b24c05..b0391ab 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -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",) diff --git a/docs/crop_blank_margins.md b/docs/crop_blank_margins.md new file mode 100644 index 0000000..0a569b8 --- /dev/null +++ b/docs/crop_blank_margins.md @@ -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 +``` diff --git a/docs/crop_exercise_bottoms.md b/docs/crop_exercise_bottoms.md new file mode 100644 index 0000000..9799b8e --- /dev/null +++ b/docs/crop_exercise_bottoms.md @@ -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. diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index cb5145c..ecbc51b 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -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") diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 91f8ba6..7101f9c 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -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",