Compare commits

..
3 Commits
Author SHA1 Message Date
sebastien e8b8a11c5b Configuration de l'output final 2026-09-12 23:56:07 +02:00
sebastien 060859ddef cropping summaries 2026-09-10 20:03:23 +02:00
sebastien dfce79f240 GUI documentation 2026-09-10 07:23:39 +02:00
24 changed files with 1352 additions and 105 deletions
+2
View File
@@ -255,6 +255,8 @@ Les chemins des étapes personnelles peuvent être adaptés avec
* Documentation complémentaire * Documentation complémentaire
- [[file:docs/final_output.md][Fichiers finaux dans A Rendre]] : contenu du JPEG, sélection et
pagination du PDF, JPEG par réponse, =score.json=, =info.json= et diffusion.
- [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments, - [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments,
prérequis, fichiers produits et parcours alternatifs. prérequis, fichiers produits et parcours alternatifs.
- [[file:Architecture.org][Architecture et conventions de développement]] : API commune, - [[file:Architecture.org][Architecture et conventions de développement]] : API commune,
+28 -3
View File
@@ -290,8 +290,31 @@ OU
3. =python -m copienator giving-names Interro BGnot= 3. =python -m copienator giving-names Interro BGnot=
Crée un dossier =A Rendre= avec des liens symboliques vers Crée un dossier =A Rendre= avec des liens symboliques vers
+ La copie à rendre + =<nom>.jpg= : la correction complète concaténée (=Concat.jpg=)
+ =<nom>.pdf=, si disponible : la correction filtrée, avec contexte,
énoncés et solutions dans le parcours =BGnot= (=Concat_F.pdf=)
+ un fichier =score.json= qui contient les notes par question + un fichier =score.json= qui contient les notes par question
+ =info.json= : pour chaque label, =present= (réponse fournie),
=not_empty= (réponse non vide compilée), =touched= (présente dans le
PDF filtré) et =score= (même valeur que dans =score.json=)
+ =answers/*.jpg=, si =RETURN_ANSWERS_ENABLED= est activé : un JPEG
par réponse non vide, contenant toujours la réponse annotée
Le PDF n'est donc pas une conversion du JPEG. Voir la
[[file:docs/final_output.md][documentation des fichiers finaux]] pour les règles de sélection,
la pagination et les différences entre parcours.
=RETURN_JPEG_ENABLED= et =RETURN_PDF_ENABLED= dans =config.py=
permettent de désactiver ces sorties séparément (activées par défaut).
Relancer =giving-names= retire les fichiers nommés désactivés en
conservant leurs sources. =score.json= et =info.json= sont toujours inclus.
=RETURN_ANSWERS_ENABLED= est désactivé par défaut, activé dans la
configuration personnelle. =RETURN_ANSWERS_CONTEXT=,
=RETURN_ANSWERS_QUESTION= et =RETURN_ANSWERS_SOLUTION= choisissent les
documents ajoutés avant chaque réponse (seule la question est activée
par défaut). Recompiler les anciennes annotations une fois avant cet
export pour produire les images finales et leurs métadonnées.
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans. Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
4. Éventuellement, faire des modifications manuelles aux =score.json=. 4. Éventuellement, faire des modifications manuelles aux =score.json=.
@@ -328,12 +351,14 @@ conserve que :
+ le résultat final =correction.json= ; + le résultat final =correction.json= ;
+ les journaux de =.copienator/logs= et les journaux placés à la + les journaux de =.copienator/logs= et les journaux placés à la
racine, comme =correction_log= ; racine, comme =correction_log= ;
+ les images et fichiers =score.json= présents dans =A Rendre=. + les images (y compris =answers=), PDF et fichiers =score.json= et
=info.json= présents dans =A Rendre=.
Les liens symboliques conservés dans =A Rendre= sont remplacés par de Les liens symboliques conservés dans =A Rendre= sont remplacés par de
véritables fichiers avant la suppression de leurs cibles. La commande véritables fichiers avant la suppression de leurs cibles. La commande
refuse de démarrer si les copies traitées, =correction.json= ou une refuse de démarrer si les copies traitées, =correction.json= ou une
image/un score d'élève sont absents. Elle affiche d'abord un résumé et image/un score d'élève sont absents (l'image est facultative si
=RETURN_JPEG_ENABLED= vaut =False=). Elle affiche d'abord un résumé et
demande de saisir le nom de l'évaluation pour confirmer. demande de saisir le nom de l'évaluation pour confirmer.
Dans le GUI, cette commande apparaît comme dernière étape facultative Dans le GUI, cette commande apparaît comme dernière étape facultative
+22
View File
@@ -0,0 +1,22 @@
from collections.abc import Collection, Mapping
from typing import Any
def build_answer_info(
scores: Mapping[str, str],
present_labels: Collection[str],
rendered_labels: Collection[str],
touched: Mapping[str, bool] | None = None,
) -> dict[str, dict[str, Any]]:
"""Describe every question using the answers actually compiled for a copy."""
present = set(present_labels)
rendered = set(rendered_labels)
return {
label: {
"present": label in present,
"not_empty": label in rendered,
"touched": (touched or {}).get(label, False),
"score": score,
}
for label, score in scores.items()
}
+6
View File
@@ -30,6 +30,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.answer_info import build_answer_info
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.utils import natural_key from copienator.utils import natural_key
@@ -446,6 +447,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
with staged_directory(output_dir) as staging: with staged_directory(output_dir) as staging:
d_notes = dict.fromkeys(all_labels, "") d_notes = dict.fromkeys(all_labels, "")
label_images = [] label_images = []
answer_labels = []
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0])) sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
for label, content in sorted_labels: for label, content in sorted_labels:
@@ -475,8 +477,12 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
final_img.save(staging / f"{label}.jpg") final_img.save(staging / f"{label}.jpg")
if result.get('error', "") != "empty-answer": if result.get('error', "") != "empty-answer":
label_images.append(final_img) label_images.append(final_img)
answer_labels.append(label)
atomic_write_json(staging / "score.json", d_notes) atomic_write_json(staging / "score.json", d_notes)
atomic_write_json(staging / "info.json", build_answer_info(
d_notes, labels_data, answer_labels
))
if label_images: if label_images:
max_w = max(image.width for image in label_images) max_w = max(image.width for image in label_images)
total_h = sum(image.height for image in label_images) total_h = sum(image.height for image in label_images)
+7 -3
View File
@@ -13,6 +13,7 @@ from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
configuration,
evaluation_parser, evaluation_parser,
execute, execute,
workspace_from_args, workspace_from_args,
@@ -83,15 +84,18 @@ def _return_artifacts(workspace: EvaluationWorkspace) -> list[Path]:
path for path in files if path.suffix.casefold() in IMAGE_SUFFIXES path for path in files if path.suffix.casefold() in IMAGE_SUFFIXES
] ]
scores = [path for path in files if path.name.casefold() == "score.json"] scores = [path for path in files if path.name.casefold() == "score.json"]
if not images or not scores: pdfs = [path for path in files if path.suffix.casefold() == ".pdf"]
if (configuration.RETURN_JPEG_ENABLED and not images) or not scores:
missing = [] missing = []
if not images: if configuration.RETURN_JPEG_ENABLED and not images:
missing.append("image") missing.append("image")
if not scores: if not scores:
missing.append("score.json") missing.append("score.json")
incomplete.append(f"{directory.name} ({', '.join(missing)})") incomplete.append(f"{directory.name} ({', '.join(missing)})")
artifacts.extend(images) artifacts.extend(images)
artifacts.extend(scores) artifacts.extend(scores)
artifacts.extend(pdfs)
artifacts.extend(path for path in files if path.name.casefold() == "info.json")
if incomplete: if incomplete:
details = "\n".join(f" - {item}" for item in incomplete) details = "\n".join(f" - {item}" for item in incomplete)
@@ -219,7 +223,7 @@ def print_plan(workspace: EvaluationWorkspace, plan: CleanupPlan, *, verbose: bo
print(f" - {statement_count} textual statement files") print(f" - {statement_count} textual statement files")
print(" - correction.json") print(" - correction.json")
print(f" - {log_count} log files") print(f" - {log_count} log files")
print(f" - {return_count} image/score artifacts in A Rendre") print(f" - {return_count} image/PDF/score artifacts in A Rendre")
print( print(
f"Will delete {len(plan.deleted_files)} files and " f"Will delete {len(plan.deleted_files)} files and "
f"{len(plan.deleted_directories)} directories " f"{len(plan.deleted_directories)} directories "
+32 -3
View File
@@ -134,6 +134,27 @@ def _publish(
return backup_root return backup_root
def crop_statistics(records: list[dict]) -> tuple[int, float]:
"""Return cropped exercise count and mean removed percentage per exercise."""
totals: dict[str, list[float]] = {}
cropped_files: set[str] = set()
for record in records:
total_height, removed_height = totals.setdefault(record["file"], [0.0, 0.0])
totals[record["file"]] = [
total_height + float(record["height_lines"]),
removed_height + float(record["bottom_removed_lines"]),
]
if record["status"] == "cropped":
cropped_files.add(record["file"])
percentages = [
min(100.0, totals[file_name][1] / totals[file_name][0] * 100)
for file_name in cropped_files
if totals[file_name][0] > 0
]
mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0
return len(cropped_files), mean_percentage
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode: def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1: if workers < 1:
raise CliError( raise CliError(
@@ -161,15 +182,23 @@ def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> Ex
raise CliError( raise CliError(
f"{source} a changé pendant lanalyse. Aucun PDF remplacé." f"{source} a changé pendant lanalyse. Aucun PDF remplacé."
) )
cropped_exercises, mean_percentage = crop_statistics(records)
if not changed_files: if not changed_files:
print("Terminé : aucun PDF ne remplit les critères de rognage.", flush=True) print("Terminé : aucun exercice ne remplit les critères de rognage.", flush=True)
print(
"Rognage moyen des exercices modifiés : 0.0 %.", flush=True
)
return ExitCode.SUCCESS return ExitCode.SUCCESS
backup = _publish(workspace, changed_files, staging, records) backup = _publish(workspace, changed_files, staging, records)
cropped_pages = sum(record["status"] == "cropped" for record in records) cropped_pages = sum(record["status"] == "cropped" for record in records)
print(f"Sauvegarde des PDF non rognés : {backup}", flush=True) print(f"Sauvegarde des PDF non rognés : {backup}", flush=True)
print( print(
f"Terminé : {cropped_pages} page(s) rognée(s) dans " f"Terminé : {cropped_exercises} exercice(s) rogné(s), soit "
f"{len(changed_files)} PDF remplacé(s).", f"{cropped_pages} page(s) dans {len(changed_files)} PDF remplacé(s).",
flush=True,
)
print(
f"Rognage moyen des exercices modifiés : {mean_percentage:.1f} %.",
flush=True, flush=True,
) )
return ExitCode.SUCCESS return ExitCode.SUCCESS
+26 -1
View File
@@ -83,6 +83,26 @@ def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict]
key=lambda row: (order[row["file"]], row["page"])) key=lambda row: (order[row["file"]], row["page"]))
def crop_statistics(records: list[dict]) -> tuple[int, float, int]:
"""Return cropped page count, their mean removed percentage, and >30% count."""
percentages: list[float] = []
for record in records:
removed_mm = record["top_removed_mm"] + record["bottom_removed_mm"]
if removed_mm <= 0:
continue
x0, y0, x1, y1 = record["original_cropbox"]
original_height_points = (
x1 - x0 if record.get("rotation", 0) % 180 else y1 - y0
)
if original_height_points <= 0:
continue
original_height_mm = original_height_points * 25.4 / 72
percentages.append(min(100.0, removed_mm / original_height_mm * 100))
mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0
over_thirty = sum(percentage > 30 for percentage in percentages)
return len(percentages), mean_percentage, over_thirty
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode: def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1: if workers < 1:
raise CliError("Le nombre de traitements parallèles doit être positif.", raise CliError("Le nombre de traitements parallèles doit être positif.",
@@ -109,9 +129,14 @@ def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> Ex
(backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2), (backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2),
encoding="utf-8") encoding="utf-8")
print(f"Sauvegarde des PDF non rognés : {originals}", flush=True) print(f"Sauvegarde des PDF non rognés : {originals}", flush=True)
cropped = sum(row["top_removed_mm"]+row["bottom_removed_mm"] > 0 for row in records) cropped, mean_percentage, over_thirty = crop_statistics(records)
print(f"Terminé : {cropped}/{len(records)} pages rognées ; " print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
f"{len(files)} PDF remplacés dans Copies.", flush=True) f"{len(files)} PDF remplacés dans Copies.", flush=True)
print(
f"Rognage moyen des pages modifiées : {mean_percentage:.1f} % ; "
f"{over_thirty} page(s) rognée(s) de plus de 30 %.",
flush=True,
)
return ExitCode.SUCCESS return ExitCode.SUCCESS
+25 -11
View File
@@ -10,12 +10,14 @@ from pathlib import Path
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
configuration,
evaluation_parser, evaluation_parser,
execute, execute,
read_json, read_json,
workspace_from_args, workspace_from_args,
) )
from copienator.platform import replace_with_link_or_copy, safe_filename from copienator.platform import replace_with_link_or_copy, safe_filename
from copienator.return_answers import publish_answer_returns
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot") ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
@@ -94,9 +96,10 @@ def prepare_named_returns(
fallback = fallback_annotations / f"Copie{copy_id}" fallback = fallback_annotations / f"Copie{copy_id}"
source_folder = None source_folder = None
for candidate in (selected, fallback): for candidate in (selected, fallback):
if (candidate / "Concat.jpg").exists() and ( if (candidate / "score.json").is_file() and (
candidate / "score.json" (candidate / "Concat.jpg").is_file()
).exists(): or (candidate / "info.json").is_file()
):
source_folder = candidate source_folder = candidate
break break
if source_folder is None: if source_folder is None:
@@ -105,19 +108,31 @@ def prepare_named_returns(
assigned_names.add(name) assigned_names.add(name)
destination = workspace.return_dir / f"{safe_name} ({copy_id})" destination = workspace.return_dir / f"{safe_name} ({copy_id})"
destination.mkdir(parents=True, exist_ok=True) destination.mkdir(parents=True, exist_ok=True)
try:
publish_answer_returns(workspace.root, source_folder, destination)
except (OSError, TypeError, ValueError) as exc:
print(f"Error preparing answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
continue
links = ( links = (
("Concat.jpg", f"{safe_name}.jpg"), ("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED),
("Concat_F.pdf", f"{safe_name}.pdf"), ("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED),
("score.json", "score.json"), ("score.json", "score.json", True),
) )
for source_name, destination_name in links: for source_name, destination_name, enabled in links:
source = source_folder / source_name source = source_folder / source_name
if not source.exists(): target = destination / destination_name
continue
try: try:
if not enabled:
# Remove only the named return entry, never its link target.
target.unlink(missing_ok=True)
continue
if not source.exists():
target.unlink(missing_ok=True)
continue
method = replace_with_link_or_copy( method = replace_with_link_or_copy(
source, source,
destination / destination_name, target,
prefer="symlink", prefer="symlink",
) )
if method == "copy": if method == "copy":
@@ -163,4 +178,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+13 -6
View File
@@ -21,6 +21,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, load_annotation_data from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.filesystem import staged_files from copienator.filesystem import staged_files
@@ -148,11 +149,12 @@ def apply_actions_and_regenerate(
raise TypeError(f"Expected a JSON object in {bnote_path}") raise TypeError(f"Expected a JSON object in {bnote_path}")
labels_data = data[student_id] labels_data = data[student_id]
dirty_labels = apply_checkbox_actions(labels_data, actions, print) apply_checkbox_actions(labels_data, actions, print)
if update_score: if update_score:
dirty_labels |= apply_score_overrides(labels_data, output_dir / "score.json", print) apply_score_overrides(labels_data, output_dir / "score.json", print)
scores = dict.fromkeys(all_labels, "") scores = dict.fromkeys(all_labels, "")
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {} dirty_images: dict[str, Image.Image] = {}
concatenated: list[Image.Image] = [] concatenated: list[Image.Image] = []
filtered: list[Image.Image] = [] filtered: list[Image.Image] = []
@@ -169,6 +171,8 @@ def apply_actions_and_regenerate(
content = labels_data[label] content = labels_data[label]
result = content["result"] result = content["result"]
scores[label] = str(result.get("score", 0)) scores[label] = str(result.get("score", 0))
if result.get("error") == "empty-answer":
continue
sub_note = None sub_note = None
if notes_layer is not None: if notes_layer is not None:
@@ -204,18 +208,22 @@ def apply_actions_and_regenerate(
body = sub_note.crop((0, old_header_height, width, height)) body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body) final_image.paste(body, (0, new_header_height), mask=body)
if label in dirty_labels or has_notes: dirty_images[label] = final_image
dirty_images[label] = final_image answer_labels.append(label)
concatenated.append(final_image) concatenated.append(final_image)
if float(scores[label]) != 4.0 or result.get("feedback", []): if float(scores[label]) != 4.0 or result.get("feedback", []):
filtered.append(final_image) filtered.append(final_image)
concat_image = concatenate(concatenated) concat_image = concatenate(concatenated)
filtered_image = concatenate(filtered) filtered_image = concatenate(filtered)
with staged_files(output_dir) as staging: with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf",
"touched.json", "answer_labels.json")) as staging:
for label, image in dirty_images.items(): for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg") image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores) atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels
))
if concat_image is not None: if concat_image is not None:
concat_image.save(staging / "Concat.jpg") concat_image.save(staging / "Concat.jpg")
if filtered_image is not None: if filtered_image is not None:
@@ -284,4 +292,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
@@ -20,6 +20,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.commands import annotating from copienator.commands import annotating
from copienator.commands.reading_annotations import ( from copienator.commands.reading_annotations import (
@@ -189,14 +190,13 @@ def apply_actions_and_regenerate_grouped(
logs = [f"\nProcessing compilation for: Copie{student_id}"] logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.root / annotation_dir / f"Copie{student_id}" output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
labels_data = data.get(student_id, {}) labels_data = data.get(student_id, {})
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append) apply_checkbox_actions(labels_data, actions, logs.append)
if update_score: if update_score:
dirty_labels |= apply_score_overrides( apply_score_overrides(
labels_data, output_dir / "score.json", logs.append labels_data, output_dir / "score.json", logs.append
) )
selected_labels = selected_labels if selected_labels is not None else set() selected_labels = selected_labels if selected_labels is not None else set()
dirty_labels |= selected_labels
simple_layout = None simple_layout = None
simple_annotated = None simple_annotated = None
if selected_labels and annotation_dir == "Anot": if selected_labels and annotation_dir == "Anot":
@@ -239,6 +239,8 @@ def apply_actions_and_regenerate_grouped(
else {} else {}
) )
scores = dict.fromkeys(all_labels, "") scores = dict.fromkeys(all_labels, "")
touched = dict.fromkeys(all_labels, False)
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {} dirty_images: dict[str, Image.Image] = {}
concat_images: list[Image.Image] = [] concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = [] filtered_groups: list[list[Image.Image]] = []
@@ -255,6 +257,9 @@ def apply_actions_and_regenerate_grouped(
): ):
result["score"] = old_scores[label] result["score"] = old_scores[label]
scores[label] = str(result.get("score", 0)) scores[label] = str(result.get("score", 0))
touched[label] = False
if result.get("error") == "empty-answer":
continue
saved_image = output_dir / f"{label}.jpg" saved_image = output_dir / f"{label}.jpg"
if selected_labels and label not in selected_labels and saved_image.is_file(): if selected_labels and label not in selected_labels and saved_image.is_file():
with Image.open(saved_image) as saved: with Image.open(saved_image) as saved:
@@ -271,12 +276,14 @@ def apply_actions_and_regenerate_grouped(
dirty_images[label] = final_image dirty_images[label] = final_image
scores[label] = str(old_scores.get(label, scores[label])) scores[label] = str(old_scores.get(label, scores[label]))
concat_images.append(final_image) concat_images.append(final_image)
answer_labels.append(label)
# Keep previously reviewed content, including handwriting. # Keep previously reviewed content, including handwriting.
if annotation_dir == "BGnot": if annotation_dir == "BGnot":
extras = get_extra_pdfs_as_images( extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels workspace.root, label, annotating, all_labels
) )
filtered_groups.append([*extras, final_image]) filtered_groups.append([*extras, final_image])
touched[label] = True
else: else:
filtered_groups.append([final_image]) filtered_groups.append([final_image])
continue continue
@@ -313,9 +320,9 @@ def apply_actions_and_regenerate_grouped(
body = sub_note.crop((0, old_header_height, width, height)) body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body) final_image.paste(body, (0, new_header_height), mask=body)
if label in dirty_labels or has_notes or selected_labels: # Persist every final block, including unchanged answers, for returns.
dirty_images[label] = final_image dirty_images[label] = final_image
logs.append(f" Saved dirty image: {label}.jpg") answer_labels.append(label)
concat_images.append(final_image) concat_images.append(final_image)
feedbacks = result.get("feedback", []) feedbacks = result.get("feedback", [])
@@ -329,11 +336,13 @@ def apply_actions_and_regenerate_grouped(
else [] else []
) )
filtered_groups.append([*extras, final_image]) filtered_groups.append([*extras, final_image])
touched[label] = annotation_dir == "BGnot"
concat_image = concatenate(concat_images) concat_image = concatenate(concat_images)
if incomplete: if incomplete:
return ExitCode.PARTIAL, "\n".join(logs) return ExitCode.PARTIAL, "\n".join(logs)
with staged_files(output_dir, remove=("Concat_F.pdf", "Concat_F.jpg")) as staging: with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.pdf", "Concat_F.jpg",
"touched.json", "answer_labels.json")) as staging:
if simple_layout is not None: if simple_layout is not None:
simple_layout["replaced"] = sorted( simple_layout["replaced"] = sorted(
set(simple_layout["replaced"]) | selected_labels set(simple_layout["replaced"]) | selected_labels
@@ -342,6 +351,9 @@ def apply_actions_and_regenerate_grouped(
for label, image in dirty_images.items(): for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg") image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores) atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels, touched
))
if concat_image is not None: if concat_image is not None:
concat_image.save(staging / "Concat.jpg") concat_image.save(staging / "Concat.jpg")
if filtered_groups: if filtered_groups:
+6
View File
@@ -33,6 +33,12 @@ else:
# Keep new optional settings compatible with older personal configuration files. # Keep new optional settings compatible with older personal configuration files.
ALWAYS_CROP = False ALWAYS_CROP = False
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
for _name in dir(_configuration): for _name in dir(_configuration):
if not _name.startswith("_"): if not _name.startswith("_"):
globals()[_name] = getattr(_configuration, _name) globals()[_name] = getattr(_configuration, _name)
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from pathlib import Path
from PIL import Image
from copienator import atomic_write_json, configuration, read_json, utils
from copienator.filesystem import staged_directory
from copienator.platform import safe_filename
def publish_answer_returns(root: Path, source: Path, destination: Path) -> None:
"""Publish individual reviewed answers and per-question information."""
scores = read_json(source / "score.json")
if not isinstance(scores, dict):
raise ValueError(f"Expected a score object in {source}")
info_path = source / "info.json"
if not info_path.is_file():
raise ValueError(f"Missing {info_path}; recompile annotations before giving-names")
info = read_json(info_path)
if not isinstance(info, dict) or set(info) != set(scores):
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
for label, entry in info.items():
if (
not isinstance(entry, dict)
or set(entry) != {"present", "not_empty", "touched", "score"}
or any(type(entry[key]) is not bool for key in ("present", "not_empty", "touched"))
or (entry["not_empty"] and not entry["present"])
or (entry["touched"] and not entry["not_empty"])
):
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
# Match score.json, including manual score edits awaiting recompilation.
entry["score"] = scores[label]
answers_dir = destination / "answers"
if answers_dir.is_symlink():
raise ValueError(f"Expected a real answer directory: {answers_dir}")
if configuration.RETURN_ANSWERS_ENABLED:
labels = [label for label, entry in info.items() if entry["present"] and entry["not_empty"]]
# Import the rendering backend only when individual images are requested.
from copienator.commands.annotating import make_base_image
from copienator.commands.reading_annotations import concatenate
all_labels = utils.read_all_labels(root)
with staged_directory(answers_dir) as staging:
for index, label in enumerate(sorted(labels, key=utils.natural_key), 1):
paths = []
if configuration.RETURN_ANSWERS_CONTEXT:
paths.extend(utils.pdf_images_of_contexts(root, label, all_labels))
if configuration.RETURN_ANSWERS_QUESTION:
paths.append(utils.pdf_image_of_enonce(root, label))
if configuration.RETURN_ANSWERS_SOLUTION:
paths.append(utils.pdf_image_of_solution(root, label))
images = []
for path in paths:
if path:
supplement, _, _ = make_base_image(path)
if supplement is None:
raise ValueError(f"Could not render {path}")
images.append(supplement)
with Image.open(source / f"{label}.jpg") as answer:
images.append(answer.convert("RGB"))
image = concatenate(images)
# Numbering avoids collisions between sanitized label filenames.
image.save(staging / f"{index:03d} - {safe_filename(label)}.jpg")
elif answers_dir.exists():
# Replace the managed directory with an empty one to remove stale exports.
with staged_directory(answers_dir):
pass
atomic_write_json(destination / "info.json", info)
(destination / "touched.json").unlink(missing_ok=True)
+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]: def copy_pdf_paths(evaluation: Path) -> list[Path]:
"""List the most relevant version of each scanned copy.""" """List the most relevant version of each scanned copy."""
locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales") locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales")
@@ -231,19 +290,33 @@ class CopienatorApp(tk.Tk):
) )
environment = ttk.Frame(top) environment = ttk.Frame(top)
environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0)) environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0))
ttk.Checkbutton( proxy_toggle = ttk.Checkbutton(
environment, environment,
text="Utiliser le proxy HTTPS", text="Utiliser le proxy HTTPS",
variable=self.use_proxy_var, variable=self.use_proxy_var,
command=self._toggle_proxy, 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 = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled")
self.proxy_entry.pack(side="left") 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, environment,
text="Afficher les détails en cas derreur (--verbose)", text="Détails derreur ⓘ",
variable=self.verbose_var, 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" profile = "standard + personnel" if show_personal_steps else "standard"
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0)) ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
@@ -647,7 +720,9 @@ class CopienatorApp(tk.Tk):
choices = spec.choices choices = spec.choices
if spec.name == "annotation_dir" and step.id in {"export", "import"}: if spec.name == "annotation_dir" and step.id in {"export", "import"}:
choices, value = self._annotation_directory_choices(step.id) 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": if spec.kind == "bool":
variable: tk.Variable = tk.BooleanVar(value=bool(value)) variable: tk.Variable = tk.BooleanVar(value=bool(value))
widget = ttk.Checkbutton(self.form, variable=variable) widget = ttk.Checkbutton(self.form, variable=variable)
@@ -672,11 +747,7 @@ class CopienatorApp(tk.Tk):
) )
self.arg_vars[spec.name] = variable self.arg_vars[spec.name] = variable
variable.trace_add("write", lambda *_args: self._update_command_preview()) variable.trace_add("write", lambda *_args: self._update_command_preview())
if spec.help: attach_tooltip(widget, spec.help)
ttk.Label(self.form, text=spec.help, foreground="#666666", wraplength=540).grid(
row=row + 1, column=1, sticky="w"
)
row += 1
row += 1 row += 1
show_extra = bool(step.extra_arguments_help) and ( show_extra = bool(step.extra_arguments_help) and (
@@ -684,18 +755,14 @@ class CopienatorApp(tk.Tk):
or variant.id in step.extra_arguments_variants or variant.id in step.extra_arguments_variants
) )
if show_extra and not step.is_manual and step.section != REFAIRE_SECTION: 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) 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)) extra_entry = ttk.Entry(self.form, textvariable=self.extra_var)
row += 1 extra_entry.grid(row=row, column=1, sticky="ew", pady=(10, 4))
ttk.Label( attach_tooltip(extra_label, step.extra_arguments_help)
self.form, attach_tooltip(extra_entry, step.extra_arguments_help)
text=step.extra_arguments_help,
foreground="#666666",
wraplength=540,
justify="left",
).grid(row=row, column=1, sticky="w")
row += 1 row += 1
if self.show_personal_steps and step.id == "statement": if self.show_personal_steps and step.id == "statement":
+151 -42
View File
@@ -61,14 +61,14 @@ class StepDefinition:
return all(variant.kind == "manual" for variant in self.variants) 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( return ArgumentSpec(
"target", "target",
"Cible", "Cible",
kind="path", kind="path",
default=EVALUATION, default=EVALUATION,
positional=True, 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"),) ) + ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
if show_personal_steps else ()), if show_personal_steps else ()),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
"restart", "restart",
"Ignorer le cache (--restart)", "Ignorer le cache (--restart)",
kind="bool", kind="bool",
flag="--restart", flag="--restart",
help="Ignore les résultats Gemini mis en cache et recommence entièrement lanalyse de l’énoncé.",
variants=("gemini",), 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 " "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.", "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",)),), (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"), optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
), ),
StepDefinition( 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. " "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.", "Les énoncés et les solutions personnels sont conservés.",
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),), (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"), optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
), ),
StepDefinition( StepDefinition(
@@ -160,7 +161,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
supports_verbose=True, supports_verbose=True,
), ),
), ),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
optional=True, optional=True,
), ),
StepDefinition( StepDefinition(
@@ -179,7 +180,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
supports_verbose=True, supports_verbose=True,
), ),
), ),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
), ),
StepDefinition( StepDefinition(
"page_splitter", "page_splitter",
@@ -187,7 +188,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Séparer et réordonner les pages", "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.", "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"),), (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"), artifacts=("Copies", "Copies Originales"),
), ),
StepDefinition( 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. " "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.", "À effectuer avant la détection des labels. Traite plusieurs copies en parallèle.",
(python("default", "Rognage des zones vides", "crop-margins"),), (python("default", "Rognage des zones vides", "crop-margins"),),
arguments=(arg_target("Dossier de l’évaluation ou PDF dans Copies"), arguments=(
ArgumentSpec("workers", "Copies traitées en parallèle", "int", "--workers", default=5)), 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, optional=True,
requires=("Copies",), requires=("Copies",),
auto_start_first_visit=ALWAYS_CROP, 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"),), (python("default", "Découpe", "crop-labels"),),
arguments=( arguments=(
arg_target(), arg_target(),
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"), ArgumentSpec(
ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked"), "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",), requires=("Copies",),
artifacts=("Cutleft",), artifacts=("Cutleft",),
@@ -226,7 +257,13 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(python("default", "Détection des labels", "labels"),), (python("default", "Détection des labels", "labels"),),
arguments=( arguments=(
arg_target(), 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"), requires=("labels", "Copies", "Cutleft"),
artifacts=("Copies/*.json",), 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.", "sauvegardés et plusieurs fichiers sont analysés en parallèle.",
(python("default", "Rognage du bas", "crop-answer-bottoms"),), (python("default", "Rognage du bas", "crop-answer-bottoms"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
"workers", "workers",
"PDF traités en parallèle", "PDF traités en parallèle",
"int", "int",
"--workers", "--workers",
default=5, 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, optional=True,
@@ -284,7 +322,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Regrouper les réponses", "Regrouper les réponses",
"Regroupe les réponses portant le même label pour préparer les requêtes.", "Regroupe les réponses portant le même label pour préparer les requêtes.",
(python("default", "Regroupement", "group-answers"),), (python("default", "Regroupement", "group-answers"),),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
requires=("Copies",), requires=("Copies",),
artifacts=("Par label",), artifacts=("Par label",),
auto_start_first_visit=True, 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), python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
), ),
arguments=( arguments=(
arg_target("Évaluation ou image Group_X.jpg"), arg_target("le dossier de l’évaluation ou une image Group_X.jpg"),
ArgumentSpec( ArgumentSpec(
"overwrite", "overwrite",
"Écraser les corrections existantes", "Écraser les corrections existantes",
"bool", "bool",
"--overwrite", "--overwrite",
help="Relance les corrections demandées même lorsquun résultat existe déjà.",
variants=("live", "batch", "hybrid", "refaire"), variants=("live", "batch", "hybrid", "refaire"),
), ),
ArgumentSpec( ArgumentSpec(
@@ -321,9 +360,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Limite dappels Pro", "Limite dappels Pro",
"int", "int",
"--limit", "--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"), 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"), requires=("Par label", "Persp", "labels"),
artifacts=("correction.json", "batch_requests_*.jsonl"), artifacts=("correction.json", "batch_requests_*.jsonl"),
@@ -339,7 +386,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Envoyer les batchs", "Envoyer les batchs",
"Envoie à Gemini les fichiers JSONL produits par le mode batch.", "Envoie à Gemini les fichiers JSONL produits par le mode batch.",
(python("default", "Envoi", "batch-submit"),), (python("default", "Envoi", "batch-submit"),),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
optional=True, optional=True,
artifacts=("batch_jobs.json",), artifacts=("batch_jobs.json",),
skip_for_live_correction=True, 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.", "Affiche les jobs Gemini en cours. Lidentifiant de téléchargement est facultatif.",
(python("default", "État des batchs", "batch-status"),), (python("default", "État des batchs", "batch-status"),),
arguments=( 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( ArgumentSpec(
"output", "output",
"Fichier JSONL de destination", "Fichier JSONL de destination",
"path", "path",
"--output", "--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, optional=True,
@@ -369,7 +422,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Récupérer les résultats batch", "Récupérer les résultats batch",
"Télécharge et rassemble les réponses des jobs terminés.", "Télécharge et rassemble les réponses des jobs terminés.",
(python("default", "Récupération", "batch-fetch"),), (python("default", "Récupération", "batch-fetch"),),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
optional=True, optional=True,
skip_for_live_correction=True, skip_for_live_correction=True,
), ),
@@ -379,7 +432,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Nettoyer la correction", "Nettoyer la correction",
"Corrige certains problèmes dencodage et prépare le texte pour LaTeX.", "Corrige certains problèmes dencodage et prépare le texte pour LaTeX.",
(python("default", "Post-correction", "post-correction"),), (python("default", "Post-correction", "post-correction"),),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
requires=("correction.json",), requires=("correction.json",),
), ),
StepDefinition( StepDefinition(
@@ -388,7 +441,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Résoudre les conflits manuels", "Résoudre les conflits manuels",
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.", "Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.",
(python("default", "Résolution", "resolve-manual"),), (python("default", "Résolution", "resolve-manual"),),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
optional=True, optional=True,
requires=("manual_resolutions.txt", "correction.json"), requires=("manual_resolutions.txt", "correction.json"),
skip_without_manual_conflicts=True, 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"), python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
), ),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"), ArgumentSpec(
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks", "grouped")), "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",), requires=("correction.json",),
artifacts=("Anot", "Bnot", "BGnot"), 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.", "Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export"),), (python("default", "Export", "export"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
"annotation_dir", "annotation_dir",
"Dossier dannotations", "Dossier dannotations",
"choice", "choice",
default="BGnot", default="BGnot",
choices=("BGnot", "Bnot", "Anot"), choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier dannotations à exporter : groupées, avec cases, ou simples.",
positional=True, 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, 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.", "Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
(python("default", "Import", "import"),), (python("default", "Import", "import"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
"annotation_dir", "annotation_dir",
"Dossier dannotations", "Dossier dannotations",
"choice", "choice",
default="BGnot", default="BGnot",
choices=("BGnot", "Bnot", "Anot"), choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier principal dans lequel importer les annotations manuscrites.",
positional=True, 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( StepDefinition(
@@ -467,9 +547,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
python("grouped", "Lecture BGnot", "read-grouped"), python("grouped", "Lecture BGnot", "read-grouped"),
), ),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"), ArgumentSpec(
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)), "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( ArgumentSpec(
"annotation_dir", "annotation_dir",
"Passage principal du mode refaire", "Passage principal du mode refaire",
@@ -477,6 +570,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"--annotation-dir", "--annotation-dir",
default="BGnot", default="BGnot",
choices=("BGnot", "Bnot", "Anot"), choices=("BGnot", "Bnot", "Anot"),
help="Indique le dossier dannotations du passage principal dans lequel intégrer les questions refaites.",
variants=("grouped",), 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.", "Crée le dossier A Rendre à partir du dossier dannotations choisi.",
(python("default", "Attribution des noms", "giving-names"),), (python("default", "Attribution des noms", "giving-names"),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
"annotation_dir", "annotation_dir",
"Dossier dannotations", "Dossier dannotations",
"choice", "choice",
default="BGnot", default="BGnot",
choices=("BGnot", "Bnot", "Anot"), choices=("BGnot", "Bnot", "Anot"),
help="Choisissez le dossier dannotations utilisé pour construire les fichiers nommés dans A Rendre.",
positional=True, 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.", "Transfère les scores, avec la possibilité de n’écrire que leur somme.",
(python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),), (python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),),
arguments=( arguments=(
arg_target("Dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"), 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, personal=True,
), ),
@@ -550,7 +651,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Ajouter le score final", "Ajouter le score final",
"Génère les fichiers de diffusion avec le score final.", "Génère les fichiers de diffusion avec le score final.",
(python("default", "Score final", "add-final-score", supports_verbose=False),), (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, personal=True,
), ),
StepDefinition( StepDefinition(
@@ -576,7 +677,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Nettoyer les fichiers intermédiaires", "Nettoyer les fichiers intermédiaires",
"Supprime définitivement les fichiers permettant de reprendre le parcours. " "Supprime définitivement les fichiers permettant de reprendre le parcours. "
"Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, " "Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, "
"les journaux, ainsi que les images et score.json de A Rendre.", "les journaux, ainsi que les images, PDF, score.json et info.json de A Rendre.",
( (
python( python(
"default", "default",
@@ -590,8 +691,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"seront supprimés. Il ne sera plus possible de reprendre une " "seront supprimés. Il ne sera plus possible de reprendre une "
"étape sans régénérer ses données.\n\n" "étape sans régénérer ses données.\n\n"
"Les PDF traités, les fichiers textuels de l’énoncé, " "Les PDF traités, les fichiers textuels de l’énoncé, "
"correction.json, les journaux, ainsi que les images et " "correction.json, les journaux, ainsi que les images, PDF et "
"score.json de A Rendre seront conservés.\n\n" "score.json et info.json de A Rendre seront conservés.\n\n"
"Continuer ?" "Continuer ?"
), ),
), ),
@@ -602,7 +703,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
fixed_args=("--dry-run",), fixed_args=("--dry-run",),
), ),
), ),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),),
optional=True, optional=True,
requires=("Copies", "correction.json", "A Rendre"), 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: for suffix, title, description, program, flags, optional in definitions:
arguments = (arg_target(),) if program else () arguments = (arg_target(),) if program else ()
if suffix == "merge": 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") requirements = ("refaire.json", "Copies", "labels", "correction.json")
if suffix in {"export", "tablet", "import", "merge"}: if suffix in {"export", "tablet", "import", "merge"}:
requirements += ("BRnot",) requirements += ("BRnot",)
+8
View File
@@ -6,6 +6,14 @@ API_KEY = os.environ.get("GEMINI_API_KEY")
EXPORT_DIR = Path("Export") EXPORT_DIR = Path("Export")
IMPORT_DIR = Path("Import") IMPORT_DIR = Path("Import")
# Fichiers à inclure dans A Rendre (les sources d'annotations sont conservées).
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
# Les étapes gestion_classe, ODS et publication sont masquées par défaut. # Les étapes gestion_classe, ODS et publication sont masquées par défaut.
SHOW_PERSONAL_STEPS = False SHOW_PERSONAL_STEPS = False
+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.
+258
View File
@@ -0,0 +1,258 @@
# Final output: `A Rendre`
This documents the current implementation, as of 2026-09-12. The return folder
referred to as « À rendre » is named **`A Rendre`** on disk. JPEG files use the
extension **`.jpg`**, not `.jpeg`.
## Files and their sources
After grouped correction and review:
```sh
python -m copienator read-grouped Interro
python -m copienator giving-names Interro BGnot
```
The expected layout for a copy is:
```text
Interro/A Rendre/
└── Student Name (01)/
├── Student Name.jpg
├── Student Name.pdf
├── score.json
├── info.json
└── answers/ # when individual answer export is enabled
├── 001 - Ex 1.jpg
└── 002 - Ex 2.jpg
```
The name comes from `Copies/Copie01.json` (`name`), with filename sanitization.
The copy ID distinguishes folders even when several copies have the same name.
`giving-names` links the full JPEG, PDF and score file (or copies them when links
are unavailable). It writes `info.json` and optionally composes the individual
answer JPEGs.
| Return file | Source under `BGnot/Copie01/` | Contents |
| --- | --- | --- |
| `Student Name.jpg` | `Concat.jpg` | Full continuous image of the compiled answers and corrections. |
| `Student Name.pdf` | `Concat_F.pdf` | Filtered, paginated correction with context, questions and solutions. |
| `score.json` | `score.json` | Per-question scores, including questions omitted from the filtered PDF. |
| `info.json` | `info.json` | Answer presence, empty-answer classification, PDF membership and score. |
| `answers/*.jpg` | Final per-label JPEGs selected by `info.json` | One annotated non-empty answer, with optional supplementary material. |
## Enabling or disabling outputs
Set these independent options in `config.py` (the defaults also apply when
absent from an older personal configuration):
```python
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
```
The personal `config.py` enables `RETURN_ANSWERS_ENABLED`; the distributed
default is `False`. Set a full-output option to `False`, then rerun `giving-names` to omit that file from
`A Rendre`. For each prepared copy, any existing named return file of a disabled
type is removed, including a symlink or fallback copy. Its annotation source
remains intact. These options control return publication, not intermediate
rendering or scoring. `score.json` and `info.json` are always included and have
no disabling options.
Cleanup allows the JPEG to be absent when disabled, still requires `score.json`,
and preserves return PDFs when present.
## Individual answer JPEGs
With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/`
subdirectory inside each student's return folder. It includes **every non-empty
compiled answer**, even a perfect answer omitted from the filtered PDF. Labels
marked `empty-answer` and labels without an answer are excluded. Every JPEG
contains the final annotated student answer, including retained feedback and
extracted handwriting.
The three supplementary options independently prepend, in this order:
1. Applicable context PDFs, if `RETURN_ANSWERS_CONTEXT` is enabled.
2. The question, if `RETURN_ANSWERS_QUESTION` is enabled (the default).
3. The model solution, if `RETURN_ANSWERS_SOLUTION` is enabled.
4. The annotated student answer, always.
These use the same `Text2`/`Sol2` sources as the filtered PDF. Missing supplements
are skipped; an unreadable existing file fails the export. They are concatenated
vertically on white, without PDF pagination or its black/blue borders. The
options affect only these individual images, not the full JPEG or filtered PDF.
Disabling all supplements produces just the annotated answer.
Filenames use natural label order, a three-digit minimum sequence number, and a
sanitized label (`001 - Ex 1.jpg`). Numbering prevents filename collisions when
labels differ only by characters forbidden in filenames. JSON keys retain exact
labels. The managed `answers/` directory is replaced on successful generation,
so removed/empty answers do not leave stale images; failures preserve the previous
directory. Disabling the option clears this directory on the next `giving-names`
run. Separate storage keeps these images out of the personal final-mark stamping
step, which reads only JPEGs directly inside the student's folder.
Recompile annotations once before exporting old evaluations with this option:
the compiler now saves every final answer block and writes `info.json`
next to them. For the grouped workflow, run `read-grouped`, then `giving-names`.
This avoids reconstructing a reviewed answer from outdated correction data.
## `info.json`: per-question information
Every label in `score.json` has an object containing exactly four fields:
```json
{
"Ex 1": {"present": true, "not_empty": true, "touched": false, "score": "4"},
"Ex 2": {"present": true, "not_empty": true, "touched": true, "score": "2"},
"Empty": {"present": true, "not_empty": false, "touched": false, "score": "0"},
"Absent": {"present": false, "not_empty": false, "touched": false, "score": ""}
}
```
- `present`: an answer entry exists for this student and label in the compilation
data. A supplied answer judged empty still has `present: true`.
- `not_empty`: the answer was not marked `empty-answer` and was successfully
compiled. Absent answers have `not_empty: false`. When individual export is
enabled, a JPEG is generated if and only if both `present` and `not_empty` are
true. These fields describe the answer regardless of export settings.
- `touched`: the answer appears in the compiled filtered `Concat_F.pdf`, using
the actual selection including handwriting and selective redo preservation.
It is not a flag for human edits. Empty and absent answers have `false`.
- `score`: the same value as `score.json` (normally a numeric string, or `""`
for an unpopulated score). Editing scores requires recompilation to update the
images; return publication uses current `score.json` values for this field.
`info.json` is always exported, even when individual JPEGs or the named PDF are
disabled. `touched` describes the source filtered PDF. In normal `Anot` and
`Bnot` flows, no filtered PDF is produced and all `touched` values are false.
This file replaces `touched.json` and the internal `answer_labels.json` manifest.
Recompile old annotations, then run `giving-names`; successful regeneration and
publication remove the obsolete files from their respective folders. Missing
or malformed metadata requires recompilation rather than guessing answer presence
from scores. Cleanup preserves `info.json` alongside `score.json`.
## JPEG: the full compiled correction
The JPEG stacks the rendered answer blocks vertically in natural label order
(for example, Ex 2 precedes Ex 10). Each block contains the scanned answer, its
label and score, retained global and local feedback, and detected handwritten
review annotations. Local feedback can include red rectangles and comments in
the left margin. Review checkboxes are applied as actions rather than reproduced
as controls; internal error labels are hidden during recompilation.
The result is one RGB image of variable height, with no page breaks. It contains
all successfully compiled answer blocks, including answers scored 4 with no
remaining feedback. “Full” refers to those answer blocks, not the original scan
pages or every question in the statement. Missing/unrenderable answers cannot
be included, and the renderer normally suppresses `empty-answer` results.
The grouped compiler refuses to publish a new set when compilation is incomplete.
The JPEG does not prepend the question, context or model solution PDFs.
## PDF: a different selection and layout
**The PDF is not a PDF conversion of the JPEG.** During an ordinary full grouped
recompilation, an answer is omitted only when all three conditions hold:
- Its score is at least 4.
- Every feedback item is marked `to_delete` (also true for an empty feedback list).
- There are no significant detected handwritten annotations for that answer.
Thus, a 4/4 answer with retained feedback or handwriting still appears. Scores
for omitted answers remain in `score.json`, and their answer blocks remain in
the JPEG. Handwriting significance currently means more than 20 pixels with
alpha greater than 50 in the extracted annotation layer.
For each retained answer, the PDF stacks the following available material:
1. Applicable context PDFs from `Text2/CTXT first_label -> last_label.pdf`.
2. The question from `Text2/<label>.pdf`.
3. The model solution from `Sol2/<label>.pdf`.
4. The same compiled answer block used for the JPEG.
Missing supplementary PDFs are skipped. Contexts can repeat for successive
questions. Each question's complete group stays together on one page; groups
are packed until the next would exceed the target height. An oversized group
gets its own taller page, rather than being split. Pages are raster images saved
as PDF at 100 dpi, with variable heights, not fixed A4 sheets or searchable text.
The target height is `int(max_image_width * 1.414 * 1.25)` pixels. Small white
margins are added on the left and above/below the page. The first image of each
group receives a black border and the second a blue border. These borders are
assigned by position, so they do not consistently identify question and solution
when contexts are present or supplementary files are missing.
If nothing survives filtering, `read-grouped` removes old `Concat_F` outputs and
does not create a new PDF. During a selective `--refaire` merge, saved answer
images outside the selection are kept in the filtered output without reapplying
the perfect-answer filter; the resulting PDF can therefore retain more answers
than a full recompilation.
## JSON: per-question scores
`score.json` is a flat JSON object keyed by the exact question labels. For example
(illustrative data):
```json
{
"Ex 1 : 1)": "4",
"Ex 1 : 2)": "2.5",
"Ex 2": ""
}
```
Values are **strings**, including numeric scores. The normal question scale is
0 to 4. `""` means no score was populated for that label; it is distinct from
`"0"`. The compiler initializes all labels from the evaluation's `labels` file
to `""`, then fills processed scores. This file contains no student identity,
feedback, annotation coordinates, grading weights, or overall final mark.
Scores incorporate review checkbox changes and, when requested, existing score
overrides via `read-grouped --update-score` (or `read-annotations --update-score`
for `Bnot`). Editing the JSON alone does not update the rendered scores. Overrides
are read from the annotation source folder: a return symlink points there, but
an independent fallback copy does not. After regeneration, rerun `giving-names`
to refresh copied return files.
`update-ods` reads these return JSON files; empty values become `NT` in the normal
per-question export. Its `--sum` option sums numeric values and skips nonnumeric
ones. Weighting and the final overall mark belong to the separate grading flow.
## Availability and later steps
- `giving-names` accepts `BGnot`, `Bnot`, or `Anot`. It selects the requested
source if `score.json` and either `Concat.jpg` or `info.json` exist,
otherwise it tries `Anot/CopieXX`. This also permits returns for an entirely
empty copy. It links the PDF only if `Concat_F.pdf` exists. The normal
`Bnot` reader produces a filtered `Concat_F.jpg`, and simple annotation produces
`Concat.jpg`; these paths do not guarantee a filtered PDF.
- Preparing returns removes disabled named outputs, and removes older named
outputs whose source is now absent, including broken symlinks.
- `add-final-score` writes to `FINAL_SCORE_OUTPUT_DIR/<evaluation>/`. It stamps
the overall mark from the configured ODS in red at the JPEG's upper right,
rounded down to one decimal place. It copies PDFs unchanged and does not export
either JSON file or the `answers/` directory. It does not add that mark to the
files inside `A Rendre`.
- The `clean` command retains return images (including individual answers), PDFs,
`score.json` and `info.json`, materializing
retained symlinks before deleting their sources.
## Implementation references
- [Naming and return links](../copienator/commands/giving_names.py)
- [Individual answer publication](../copienator/return_answers.py)
- [Grouped compilation, filtering and PDF pagination](../copienator/commands/reading_grouped_annotations.py)
- [Answer rendering](../copienator/commands/annotating.py)
- [Handwriting detection and per-copy compilation](../copienator/commands/reading_annotations.py)
- [Score and feedback actions](../copienator/annotation_actions.py)
- [Context, question and solution lookup](../copienator/utils.py)
- [ODS export](../copienator/commands/update_ods.py)
- [Final-mark stamping](../copienator/commands/add_final_score.py)
- [Cleanup retention](../copienator/commands/clean.py)
+217
View File
@@ -0,0 +1,217 @@
import contextlib
import io
import itertools
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json
from copienator.commands import annotating, giving_names
from copienator.commands import reading_grouped_annotations as reader
from copienator.return_answers import publish_answer_returns
class AnswerReturnTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.source = self.root / "BGnot" / "Copie01"
self.source.mkdir(parents=True)
self.destination = self.root / "A Rendre" / "Student (01)"
self.destination.mkdir(parents=True)
(self.root / "labels").write_text("Ex 1\nEx 2\nEmpty\n")
atomic_write_json(self.source / "score.json", {"Ex 1": "4", "Ex 2": "2", "Empty": "0"})
atomic_write_json(self.source / "info.json", {
"Ex 1": {"present": True, "not_empty": True, "touched": False, "score": "4"},
"Ex 2": {"present": True, "not_empty": True, "touched": True, "score": "2"},
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"},
})
for label in ("Ex 1", "Ex 2", "Empty"):
Image.new("RGB", (100, 30), "red").save(self.source / f"{label}.jpg")
for folder in ("Text2", "Sol2"):
(self.root / folder).mkdir()
for label in ("Ex 1", "Ex 2"):
(self.root / "Text2" / f"{label}.pdf").touch()
(self.root / "Sol2" / f"{label}.pdf").touch()
(self.root / "Text2" / "CTXT Ex 1 -> Ex 2.pdf").touch()
@staticmethod
def supplement(path):
path = Path(path)
color = "blue" if path.name.startswith("CTXT") else "green" if path.parent.name == "Text2" else "yellow"
return Image.new("RGB", (100, 20), color), 0, 0
def test_all_supplement_combinations_always_include_annotated_answer(self):
for context, question, solution in itertools.product((False, True), repeat=3):
with self.subTest(context=context, question=question, solution=solution), patch.multiple(
configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=context, RETURN_ANSWERS_QUESTION=question,
RETURN_ANSWERS_SOLUTION=solution,
), patch.object(annotating, "make_base_image", side_effect=self.supplement):
publish_answer_returns(self.root, self.source, self.destination)
files = sorted((self.destination / "answers").glob("*.jpg"))
self.assertEqual([p.name for p in files], ["001 - Ex 1.jpg", "002 - Ex 2.jpg"])
with Image.open(files[0]) as image:
self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution))))
colors = []
if context:
colors.append((0, 0, 255))
if question:
colors.append((0, 128, 0))
if solution:
colors.append((255, 255, 0))
colors.append((255, 0, 0))
for index, color in enumerate(colors):
pixel = image.getpixel((50, index * 20 + 10))
self.assertTrue(all(abs(a - b) < 10 for a, b in zip(pixel, color)))
self.assertEqual(read_json(self.destination / "info.json"), read_json(self.source / "info.json"))
def test_missing_supplement_is_optional_and_failure_preserves_old_answers(self):
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=True,
RETURN_ANSWERS_SOLUTION=False), patch.object(
annotating, "make_base_image", side_effect=self.supplement
):
(self.root / "Text2" / "Ex 1.pdf").unlink()
publish_answer_returns(self.root, self.source, self.destination)
path = self.destination / "answers" / "001 - Ex 1.jpg"
with Image.open(path) as image:
self.assertEqual(image.size, (100, 30))
original = path.read_bytes()
(self.source / "Ex 2.jpg").unlink()
with self.assertRaises(FileNotFoundError):
publish_answer_returns(self.root, self.source, self.destination)
self.assertEqual(path.read_bytes(), original)
self.assertTrue((self.destination / "answers" / "002 - Ex 2.jpg").exists())
def test_disabling_clears_individual_images_but_retains_info(self):
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
RETURN_ANSWERS_SOLUTION=False):
publish_answer_returns(self.root, self.source, self.destination)
with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False):
publish_answer_returns(self.root, self.source, self.destination)
self.assertEqual(list((self.destination / "answers").iterdir()), [])
self.assertTrue(read_json(self.destination / "info.json")["Ex 2"]["touched"])
def test_missing_info_requires_recompilation_instead_of_guessing(self):
(self.source / "info.json").unlink()
(self.source / "Concat_F.pdf").touch()
with self.assertRaisesRegex(ValueError, "recompile"):
publish_answer_returns(self.root, self.source, self.destination)
def test_info_replaces_touched_and_tracks_manual_score_edits(self):
atomic_write_json(self.destination / "touched.json", {"obsolete": True})
atomic_write_json(self.source / "score.json", {"Ex 1": "3.5", "Ex 2": "2", "Empty": "0"})
with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False):
publish_answer_returns(self.root, self.source, self.destination)
self.assertFalse((self.destination / "touched.json").exists())
self.assertEqual(read_json(self.destination / "info.json")["Ex 1"], {
"present": True, "not_empty": True, "touched": False, "score": "3.5"
})
def test_invalid_info_preserves_previous_return(self):
atomic_write_json(self.destination / "info.json", {"previous": "keep"})
info = read_json(self.source / "info.json")
info["Empty"]["touched"] = True
atomic_write_json(self.source / "info.json", info)
with self.assertRaisesRegex(ValueError, "Invalid question information"):
publish_answer_returns(self.root, self.source, self.destination)
self.assertEqual(read_json(self.destination / "info.json"), {"previous": "keep"})
def test_publication_is_independent_of_full_jpeg_and_pdf_options(self):
workspace = EvaluationWorkspace(self.root)
workspace.copies_dir.mkdir()
atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"})
(self.root / "names").write_text("Student\n")
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
RETURN_ANSWERS_SOLUTION=False, RETURN_JPEG_ENABLED=False,
RETURN_PDF_ENABLED=False), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(giving_names.run(workspace, annotation_dir="BGnot"), 0)
self.assertEqual({p.name for p in self.destination.iterdir()}, {"answers", "score.json", "info.json"})
class CompiledMembershipTests(unittest.TestCase):
def test_membership_matches_actual_groups_and_saves_every_nonempty_answer(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
workspace = EvaluationWorkspace(root)
output = root / "BGnot" / "Copie01"
output.mkdir(parents=True)
answer = root / "answer.pdf"
answer.touch()
results = {
"Perfect": {"score": 4, "feedback": []},
"Low": {"score": 2, "feedback": []},
"Feedback": {"score": 4, "feedback": [{"text": "keep"}]},
"Deleted": {"score": 4, "feedback": [{"text": "delete", "to_delete": True}]},
"Handwriting": {"score": 4, "feedback": []},
"Empty": {"score": 0, "error": "empty-answer"},
}
data = {"01": {label: {"result": result, "pdf_path": answer, "coordinates": (0, 0)}
for label, result in results.items()}}
all_labels = [*results, "Absent"]
rendered = []
def compose(base, label, *args, **kwargs):
rendered.append(label)
return Image.new("RGB", (100, 50), "white"), 0
notes = {"Handwriting": {"img": Image.new("RGBA", (100, 50), "red"), "old_header_h": 0}}
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), patch.object(
annotating, "compose_label_image", side_effect=compose
), patch.object(reader, "get_extra_pdfs_as_images", return_value=[]), patch.object(
reader, "save_paginated_pdf"
) as save_pdf:
status, _ = reader.apply_actions_and_regenerate_grouped(workspace, data, "01", [], notes, all_labels)
self.assertEqual(status, 0)
info = read_json(output / "info.json")
touched = {label: entry["touched"] for label, entry in info.items()}
self.assertEqual({label for label, value in touched.items() if value}, {"Low", "Feedback", "Handwriting"})
self.assertEqual(len(save_pdf.call_args.args[0]), sum(touched.values()))
self.assertEqual({label for label, entry in info.items() if entry["present"] and entry["not_empty"]}, set(results) - {"Empty"})
self.assertEqual(info["Empty"], {"present": True, "not_empty": False, "touched": False, "score": "0"})
self.assertEqual(info["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""})
self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"})
self.assertNotIn("Empty", rendered)
for label in rendered:
self.assertTrue((output / f"{label}.jpg").is_file())
# Selective redo keeps unselected saved answers in the PDF,
# including previously perfect answers; touched must follow it.
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace, data, "01", [], {}, all_labels, selected_labels={"Low"}
)
self.assertEqual(status, 0)
self.assertTrue(read_json(output / "info.json")["Perfect"]["touched"])
self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"])
def test_all_empty_removes_stale_concat_and_records_false(self):
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
output = workspace.root / "BGnot" / "Copie01"
output.mkdir(parents=True)
for name in ("Concat.jpg", "Concat_F.pdf"):
(output / name).write_bytes(b"stale")
atomic_write_json(output / "touched.json", {"Empty": True})
atomic_write_json(output / "answer_labels.json", ["Empty"])
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}}}},
"01", [], {}, ["Empty"]
)
self.assertEqual(status, 0)
self.assertEqual(read_json(output / "info.json"), {
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}
})
self.assertFalse((output / "Concat.jpg").exists())
self.assertFalse((output / "Concat_F.pdf").exists())
self.assertFalse((output / "touched.json").exists())
self.assertFalse((output / "answer_labels.json").exists())
if __name__ == "__main__":
unittest.main()
@@ -49,6 +49,8 @@ class CropExerciseBottomsCommandTests(unittest.TestCase):
0, 0,
) )
self.assertIn("1 PDF remplacé", log.getvalue()) self.assertIn("1 PDF remplacé", log.getvalue())
self.assertIn("1 exercice(s) rogné(s)", log.getvalue())
self.assertIn("Rognage moyen des exercices modifiés", log.getvalue())
with pymupdf.open(self.large) as cropped: with pymupdf.open(self.large) as cropped:
self.assertLess(cropped[0].rect.height, 250) self.assertLess(cropped[0].rect.height, 250)
self.assertEqual(self.short.read_bytes(), self.short_original) self.assertEqual(self.short.read_bytes(), self.short_original)
@@ -68,6 +70,37 @@ class CropExerciseBottomsCommandTests(unittest.TestCase):
) )
self.assertTrue((backups[0].parents[2] / "report.json").is_file()) self.assertTrue((backups[0].parents[2] / "report.json").is_file())
def test_crop_statistics_average_percentages_by_exercise(self):
records = [
{
"file": "Copies/Copie01/Ex 1.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 4.0,
"status": "cropped",
},
{
"file": "Copies/Copie01/Ex 1.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 0.0,
"status": "skipped-short",
},
{
"file": "Copies/Copie01/Ex 2.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 5.0,
"status": "cropped",
},
{
"file": "Copies/Copie01/Ex 3.pdf",
"height_lines": 10.0,
"bottom_removed_lines": 0.0,
"status": "unchanged-small-crop",
},
]
count, average = crop_exercise_bottoms.crop_statistics(records)
self.assertEqual(count, 2)
self.assertAlmostEqual(average, 35.0)
def test_detection_failure_does_not_publish_an_earlier_result(self): def test_detection_failure_does_not_publish_an_earlier_result(self):
broken = self.answers / "Ex 3.pdf" broken = self.answers / "Ex 3.pdf"
broken.write_bytes(b"not a PDF") broken.write_bytes(b"not a PDF")
+29
View File
@@ -37,6 +37,9 @@ class CropMarginsCommandTests(unittest.TestCase):
with contextlib.redirect_stdout(io.StringIO()) as log: with contextlib.redirect_stdout(io.StringIO()) as log:
self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0) self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0)
self.assertIn("Page 2/2", log.getvalue()) self.assertIn("Page 2/2", log.getvalue())
self.assertIn("1/2 pages rognées", log.getvalue())
self.assertIn("Rognage moyen des pages modifiées", log.getvalue())
self.assertIn("page(s) rognée(s) de plus de 30 %", log.getvalue())
with pymupdf.open(self.source) as result: with pymupdf.open(self.source) as result:
self.assertEqual(len(result), 2) self.assertEqual(len(result), 2)
self.assertLess(result[0].rect.height, 150) self.assertLess(result[0].rect.height, 150)
@@ -50,6 +53,32 @@ class CropMarginsCommandTests(unittest.TestCase):
self.assertEqual(backups[0].read_bytes(), self.original) self.assertEqual(backups[0].read_bytes(), self.original)
self.assertTrue((backups[0].parent.parent/"report.json").is_file()) self.assertTrue((backups[0].parent.parent/"report.json").is_file())
def test_crop_statistics_use_only_modified_pages(self):
records = [
{
"top_removed_mm": 10.0,
"bottom_removed_mm": 20.0,
"original_cropbox": [0, 0, 200, 300],
"rotation": 0,
},
{
"top_removed_mm": 40.0,
"bottom_removed_mm": 0.0,
"original_cropbox": [0, 0, 200, 300],
"rotation": 90,
},
{
"top_removed_mm": 0.0,
"bottom_removed_mm": 0.0,
"original_cropbox": [0, 0, 200, 300],
"rotation": 0,
},
]
count, average, over_thirty = crop_margins.crop_statistics(records)
self.assertEqual(count, 2)
self.assertAlmostEqual(average, (28.3465 + 56.6929) / 2, places=3)
self.assertEqual(over_thirty, 1)
def test_failure_or_interruption_never_publishes_partial_batch(self): def test_failure_or_interruption_never_publishes_partial_batch(self):
second = self.workspace.copies_dir/"Copie02.pdf" second = self.workspace.copies_dir/"Copie02.pdf"
second.write_bytes(self.original) second.write_bytes(self.original)
+36 -3
View File
@@ -153,12 +153,45 @@ class GuiConvenienceTests(unittest.TestCase):
self.app.tree.selection_set("labels") self.app.tree.selection_set("labels")
self.app.update() self.app.update()
labels_text = self._label_texts(self.app.form) labels_text = self._label_texts(self.app.form)
self.assertIn("Arguments supplémentaires", labels_text) extra_label = next(
self.assertTrue(any("Cutleft" in text for text in labels_text)) 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.tree.selection_set("plotting")
self.app.update() 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): def test_verbose_checkbox_updates_supported_commands(self):
self.app.tree.selection_set("labels") self.app.tree.selection_set("labels")
+11 -5
View File
@@ -502,11 +502,6 @@ class StandardCliTests(unittest.TestCase):
"default", "default",
{"target": evaluation}, {"target": evaluation},
), ),
"verify_groups": (
"verify_groups",
"default",
{"target": evaluation},
),
"annotating": ( "annotating": (
"annotation", "annotation",
"simple", "simple",
@@ -690,6 +685,10 @@ class StandardCliTests(unittest.TestCase):
annotations.mkdir(parents=True) annotations.mkdir(parents=True)
atomic_write_json(copies / "Copie01.json", {"name": "Élève Test"}) atomic_write_json(copies / "Copie01.json", {"name": "Élève Test"})
atomic_write_json(annotations / "score.json", {"total": 10}) atomic_write_json(annotations / "score.json", {"total": 10})
atomic_write_json(annotations / "info.json", {
"total": {"present": False, "not_empty": False, "touched": False, "score": 10}
})
(evaluation / "labels").write_text("total\n")
(annotations / "Concat.jpg").write_bytes(b"image") (annotations / "Concat.jpg").write_bytes(b"image")
(evaluation / "names").write_text("Élève Test\n", encoding="utf-8") (evaluation / "names").write_text("Élève Test\n", encoding="utf-8")
@@ -1829,6 +1828,13 @@ class WorkflowTests(unittest.TestCase):
("live", "batch", "hybrid", "refaire"), ("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: def test_options_previously_requiring_free_form_arguments_have_controls(self) -> None:
status = self.command( status = self.command(
"batch_status", "batch_status",
+124
View File
@@ -0,0 +1,124 @@
import contextlib
import io
import itertools
import runpy
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from copienator import EvaluationWorkspace, atomic_write_json, configuration
from copienator.commands import clean, giving_names
class ReturnOutputTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workspace = EvaluationWorkspace(Path(self.temp.name))
self.workspace.copies_dir.mkdir()
atomic_write_json(self.workspace.copies_dir / "Copie01.json", {"name": "Student"})
(self.workspace.copies_dir / "Copie01.pdf").write_bytes(b"original")
atomic_write_json(self.workspace.correction_file, {})
(self.workspace.root / "names").write_text("Student\n")
self.source = self.workspace.root / "BGnot" / "Copie01"
self.source.mkdir(parents=True)
(self.source / "Concat.jpg").write_bytes(b"jpeg")
(self.source / "Concat_F.pdf").write_bytes(b"pdf")
atomic_write_json(self.source / "score.json", {"Ex 1": "4"})
atomic_write_json(self.source / "info.json", {
"Ex 1": {"present": True, "not_empty": True, "touched": True, "score": "4"}
})
self.answer_option = patch.object(configuration, "RETURN_ANSWERS_ENABLED", False)
self.answer_option.start()
self.addCleanup(self.answer_option.stop)
self.destination = self.workspace.return_dir / "Student (01)"
def prepare(self, jpeg=True, pdf=True):
with patch.object(configuration, "RETURN_JPEG_ENABLED", jpeg), patch.object(
configuration, "RETURN_PDF_ENABLED", pdf
), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(giving_names.run(self.workspace, annotation_dir="BGnot"), 0)
def test_all_combinations_and_reenable_preserve_sources_and_scores(self):
for jpeg, pdf in itertools.product((True, False), repeat=2):
with self.subTest(jpeg=jpeg, pdf=pdf):
self.prepare()
self.prepare(jpeg, pdf)
expected = {"score.json", "info.json"}
if jpeg:
expected.add("Student.jpg")
if pdf:
expected.add("Student.pdf")
self.assertEqual({p.name for p in self.destination.iterdir()}, expected)
self.assertEqual((self.source / "Concat.jpg").read_bytes(), b"jpeg")
self.assertEqual((self.source / "Concat_F.pdf").read_bytes(), b"pdf")
self.assertEqual(
(self.destination / "score.json").read_bytes(),
(self.source / "score.json").read_bytes(),
)
self.prepare()
self.assertEqual((self.destination / "Student.jpg").read_bytes(), b"jpeg")
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
def test_disabling_removes_regular_files_and_broken_links_only(self):
self.prepare()
jpg = self.destination / "Student.jpg"
jpg.unlink()
jpg.write_bytes(b"old fallback copy")
pdf = self.destination / "Student.pdf"
pdf.unlink()
pdf.symlink_to(self.source / "missing.pdf")
unrelated = self.destination / "notes.txt"
unrelated.write_text("keep")
self.prepare(False, False)
self.assertEqual({p.name for p in self.destination.iterdir()}, {"score.json", "info.json", "notes.txt"})
self.assertTrue((self.source / "Concat_F.pdf").is_file())
def test_fallback_source_respects_options(self):
self.source.parent.rename(self.workspace.root / "Anot")
(self.workspace.root / "BGnot").mkdir()
self.prepare(False, True)
self.assertFalse((self.destination / "Student.jpg").exists())
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
self.assertTrue((self.destination / "score.json").is_file())
self.assertTrue((self.destination / "info.json").is_file())
def test_cleanup_preserves_enabled_returns_without_jpeg(self):
self.prepare(False, True)
with patch.object(configuration, "RETURN_JPEG_ENABLED", False):
plan = clean.build_cleanup_plan(self.workspace)
with contextlib.redirect_stdout(io.StringIO()):
clean.apply_cleanup(self.workspace, plan)
self.assertFalse(self.source.exists())
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
self.assertFalse((self.destination / "Student.pdf").is_symlink())
self.assertTrue((self.destination / "score.json").is_file())
self.assertTrue((self.destination / "info.json").is_file())
def test_cleanup_accepts_scores_only_but_still_requires_scores(self):
self.prepare(False, False)
with patch.object(configuration, "RETURN_JPEG_ENABLED", False):
plan = clean.build_cleanup_plan(self.workspace)
self.assertIn(self.destination / "score.json", plan.kept_files)
(self.destination / "score.json").unlink()
with self.assertRaisesRegex(clean.CliError, "score.json"):
clean.build_cleanup_plan(self.workspace)
def test_older_config_defaults_to_enabled(self):
old_config = self.workspace.root / "old_config.py"
old_config.write_text("ALWAYS_CROP = False\n")
with patch.dict("os.environ", {"COPIENATOR_CONFIG": str(old_config)}):
loaded = runpy.run_path(configuration.__file__)
self.assertIs(loaded["RETURN_JPEG_ENABLED"], True)
self.assertIs(loaded["RETURN_PDF_ENABLED"], True)
self.assertIs(loaded["RETURN_ANSWERS_ENABLED"], False)
self.assertIs(loaded["RETURN_ANSWERS_CONTEXT"], False)
self.assertIs(loaded["RETURN_ANSWERS_QUESTION"], True)
self.assertIs(loaded["RETURN_ANSWERS_SOLUTION"], False)
self.assertNotIn("RETURN_JSON_ENABLED", loaded)
if __name__ == "__main__":
unittest.main()