Compare commits
2
Commits
698ac4240c
...
3754c0c11a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3754c0c11a | ||
|
|
370f3252ef |
No files matched your search
@@ -9,20 +9,26 @@ import numpy as np
|
|||||||
from pdf2image import convert_from_path
|
from pdf2image import convert_from_path
|
||||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||||
|
|
||||||
from copienator.commands import annotating
|
|
||||||
from copienator import configuration, utils
|
|
||||||
from copienator import (
|
from copienator import (
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
ExitCode,
|
ExitCode,
|
||||||
atomic_write_json,
|
atomic_write_json,
|
||||||
|
configuration,
|
||||||
evaluation_parser,
|
evaluation_parser,
|
||||||
execute,
|
execute,
|
||||||
read_json,
|
read_json,
|
||||||
|
utils,
|
||||||
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.annotation_modifications import (
|
||||||
|
BASELINE,
|
||||||
|
output_removals,
|
||||||
|
stage_baseline,
|
||||||
|
)
|
||||||
|
from copienator.answer_info import build_answer_info
|
||||||
|
from copienator.commands import annotating
|
||||||
from copienator.filesystem import staged_files
|
from copienator.filesystem import staged_files
|
||||||
from copienator.return_answers import save_return_answer_options
|
from copienator.return_answers import save_return_answer_options
|
||||||
|
|
||||||
@@ -168,6 +174,8 @@ def apply_actions_and_regenerate(
|
|||||||
scores = dict.fromkeys(all_labels, "")
|
scores = dict.fromkeys(all_labels, "")
|
||||||
answer_labels: list[str] = []
|
answer_labels: list[str] = []
|
||||||
dirty_images: dict[str, Image.Image] = {}
|
dirty_images: dict[str, Image.Image] = {}
|
||||||
|
header_heights: dict[str, int] = {}
|
||||||
|
filtered_labels: list[str] = []
|
||||||
concatenated: list[Image.Image] = []
|
concatenated: list[Image.Image] = []
|
||||||
filtered: list[Image.Image] = []
|
filtered: list[Image.Image] = []
|
||||||
incomplete = False
|
incomplete = False
|
||||||
@@ -221,15 +229,18 @@ def apply_actions_and_regenerate(
|
|||||||
final_image.paste(body, (0, new_header_height), mask=body)
|
final_image.paste(body, (0, new_header_height), mask=body)
|
||||||
|
|
||||||
dirty_images[label] = final_image
|
dirty_images[label] = final_image
|
||||||
|
header_heights[label] = new_header_height
|
||||||
answer_labels.append(label)
|
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)
|
||||||
|
filtered_labels.append(label)
|
||||||
|
|
||||||
|
if incomplete:
|
||||||
|
return ExitCode.PARTIAL
|
||||||
concat_image = concatenate(concatenated)
|
concat_image = concatenate(concatenated)
|
||||||
filtered_image = concatenate(filtered)
|
filtered_image = concatenate(filtered)
|
||||||
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf",
|
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
|
||||||
"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")
|
||||||
if not preserve_score_file:
|
if not preserve_score_file:
|
||||||
@@ -241,6 +252,9 @@ def apply_actions_and_regenerate(
|
|||||||
concat_image.save(staging / "Concat.jpg")
|
concat_image.save(staging / "Concat.jpg")
|
||||||
if filtered_image is not None:
|
if filtered_image is not None:
|
||||||
filtered_image.save(staging / "Concat_F.jpg")
|
filtered_image.save(staging / "Concat_F.jpg")
|
||||||
|
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
|
||||||
|
header_heights, build_answer_info(scores, labels_data, answer_labels),
|
||||||
|
filtered_labels, scores)
|
||||||
|
|
||||||
if preserve_score_file:
|
if preserve_score_file:
|
||||||
print(f" Preserved existing score.json in {output_dir}")
|
print(f" Preserved existing score.json in {output_dir}")
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import unicodedata
|
||||||
|
from hashlib import sha256
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
from copienator import configuration
|
|
||||||
from copienator import (
|
from copienator import (
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
ExitCode,
|
ExitCode,
|
||||||
atomic_write_json,
|
atomic_write_json,
|
||||||
|
configuration,
|
||||||
evaluation_parser,
|
evaluation_parser,
|
||||||
execute,
|
execute,
|
||||||
read_json,
|
read_json,
|
||||||
@@ -21,8 +24,15 @@ 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.annotation_modifications import (
|
||||||
|
BASELINE,
|
||||||
|
baseline,
|
||||||
|
load_image,
|
||||||
|
output_removals,
|
||||||
|
stage_baseline,
|
||||||
|
)
|
||||||
|
from copienator.answer_info import build_answer_info
|
||||||
from copienator.commands import annotating
|
from copienator.commands import annotating
|
||||||
from copienator.commands.reading_annotations import (
|
from copienator.commands.reading_annotations import (
|
||||||
concatenate,
|
concatenate,
|
||||||
@@ -37,24 +47,51 @@ ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
|
|||||||
SCAN_WORKERS = 2
|
SCAN_WORKERS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def context_content_key(path: str | Path) -> tuple[str, str]:
|
||||||
|
"""Identify context by its text, independently of filename and PDF metadata."""
|
||||||
|
with pymupdf.open(path) as document:
|
||||||
|
text = "\n".join(page.get_text(sort=True) for page in document)
|
||||||
|
text = " ".join(unicodedata.normalize("NFC", text).split())
|
||||||
|
if text:
|
||||||
|
return "text", text
|
||||||
|
# Image-only contexts must not all collapse to the same empty-text key.
|
||||||
|
return "file", sha256(Path(path).read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def get_extra_pdfs_as_images(
|
def get_extra_pdfs_as_images(
|
||||||
root_dir: str | Path,
|
root_dir: str | Path,
|
||||||
label: str,
|
label: str,
|
||||||
annotating_module: Any,
|
annotating_module: Any,
|
||||||
all_labels: list[str],
|
all_labels: list[str],
|
||||||
|
*,
|
||||||
|
seen_contexts: set[tuple[str, str]] | None = None,
|
||||||
) -> list[Image.Image]:
|
) -> list[Image.Image]:
|
||||||
"""Convert the context, question and solution PDFs associated with a label."""
|
"""Render supplements with semantic borders and optional per-copy deduplication."""
|
||||||
paths = [
|
paths = [
|
||||||
*utils.pdf_images_of_contexts(root_dir, label, all_labels),
|
*(("context", path) for path in utils.pdf_images_of_contexts(root_dir, label, all_labels)),
|
||||||
utils.pdf_image_of_enonce(root_dir, label),
|
("question", utils.pdf_image_of_enonce(root_dir, label)),
|
||||||
utils.pdf_image_of_solution(root_dir, label),
|
("solution", utils.pdf_image_of_solution(root_dir, label)),
|
||||||
]
|
]
|
||||||
images = []
|
images = []
|
||||||
for path in paths:
|
for role, path in paths:
|
||||||
if path:
|
if path:
|
||||||
|
key = None
|
||||||
|
if role == "context" and seen_contexts is not None:
|
||||||
|
key = context_content_key(path)
|
||||||
|
if key in seen_contexts:
|
||||||
|
continue
|
||||||
image, _, _ = annotating_module.make_base_image(path)
|
image, _, _ = annotating_module.make_base_image(path)
|
||||||
if image is not None:
|
if image is not None:
|
||||||
|
if role in {"question", "solution"}:
|
||||||
|
image = image.copy()
|
||||||
|
ImageDraw.Draw(image).rectangle(
|
||||||
|
[0, 0, image.width - 1, image.height - 1],
|
||||||
|
outline="black" if role == "question" else "blue",
|
||||||
|
width=int((0.2 / 2.54) * 100),
|
||||||
|
)
|
||||||
images.append(image)
|
images.append(image)
|
||||||
|
if key is not None:
|
||||||
|
seen_contexts.add(key)
|
||||||
return images
|
return images
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +104,6 @@ def save_paginated_pdf(
|
|||||||
return
|
return
|
||||||
max_width = max(image.width for group in non_empty for image in group)
|
max_width = max(image.width for group in non_empty for image in group)
|
||||||
max_page_height = int(max_width * 1.414 * 1.25)
|
max_page_height = int(max_width * 1.414 * 1.25)
|
||||||
border = int((0.2 / 2.54) * 100)
|
|
||||||
left_margin = int((0.3 / 2.54) * 100)
|
left_margin = int((0.3 / 2.54) * 100)
|
||||||
vertical_margin = int((0.2 / 2.54) * 100)
|
vertical_margin = int((0.2 / 2.54) * 100)
|
||||||
max_content_height = max_page_height - 2 * vertical_margin
|
max_content_height = max_page_height - 2 * vertical_margin
|
||||||
@@ -94,21 +130,10 @@ def save_paginated_pdf(
|
|||||||
page_height = 0
|
page_height = 0
|
||||||
|
|
||||||
for group in non_empty:
|
for group in non_empty:
|
||||||
processed: list[Image.Image] = []
|
group_height = sum(image.height for image in group)
|
||||||
for index, image in enumerate(group):
|
|
||||||
if index in (0, 1):
|
|
||||||
image = image.copy()
|
|
||||||
color = "black" if index == 0 else "blue"
|
|
||||||
ImageDraw.Draw(image).rectangle(
|
|
||||||
[0, 0, image.width - 1, image.height - 1],
|
|
||||||
outline=color,
|
|
||||||
width=border,
|
|
||||||
)
|
|
||||||
processed.append(image)
|
|
||||||
group_height = sum(image.height for image in processed)
|
|
||||||
if page_images and page_height + group_height > max_content_height:
|
if page_images and page_height + group_height > max_content_height:
|
||||||
finish_page()
|
finish_page()
|
||||||
page_images.extend(processed)
|
page_images.extend(group)
|
||||||
page_height += group_height
|
page_height += group_height
|
||||||
finish_page()
|
finish_page()
|
||||||
pages[0].save(
|
pages[0].save(
|
||||||
@@ -247,8 +272,12 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
touched = dict.fromkeys(all_labels, False)
|
touched = dict.fromkeys(all_labels, False)
|
||||||
answer_labels: list[str] = []
|
answer_labels: list[str] = []
|
||||||
dirty_images: dict[str, Image.Image] = {}
|
dirty_images: dict[str, Image.Image] = {}
|
||||||
|
header_heights: dict[str, int] = {}
|
||||||
|
filtered_labels: list[str] = []
|
||||||
|
previous_baseline = baseline(output_dir) if (output_dir / BASELINE).is_file() else None
|
||||||
concat_images: list[Image.Image] = []
|
concat_images: list[Image.Image] = []
|
||||||
filtered_groups: list[list[Image.Image]] = []
|
filtered_groups: list[list[Image.Image]] = []
|
||||||
|
seen_contexts: set[tuple[str, str]] = set()
|
||||||
incomplete = False
|
incomplete = False
|
||||||
|
|
||||||
for label, content in sorted(
|
for label, content in sorted(
|
||||||
@@ -266,7 +295,17 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
if result.get("error") == "empty-answer":
|
if result.get("error") == "empty-answer":
|
||||||
continue
|
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():
|
baseline_entry = (previous_baseline or {}).get("images", {}).get(label)
|
||||||
|
if selected_labels and label not in selected_labels and (saved_image.is_file() or baseline_entry):
|
||||||
|
if annotation_dir in {"BGnot", "Bnot"}:
|
||||||
|
if previous_baseline is not None and label in previous_baseline["images"]:
|
||||||
|
entry = previous_baseline["images"][label]
|
||||||
|
final_image = load_image(output_dir, entry)
|
||||||
|
header_heights[label] = entry["header_height"]
|
||||||
|
else:
|
||||||
|
with Image.open(saved_image) as saved:
|
||||||
|
final_image = saved.convert("RGB").copy()
|
||||||
|
else:
|
||||||
with Image.open(saved_image) as saved:
|
with Image.open(saved_image) as saved:
|
||||||
final_image = saved.convert("RGB").copy()
|
final_image = saved.convert("RGB").copy()
|
||||||
if (
|
if (
|
||||||
@@ -282,10 +321,11 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
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)
|
answer_labels.append(label)
|
||||||
|
filtered_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, seen_contexts=seen_contexts
|
||||||
)
|
)
|
||||||
filtered_groups.append([*extras, final_image])
|
filtered_groups.append([*extras, final_image])
|
||||||
touched[label] = True
|
touched[label] = True
|
||||||
@@ -327,6 +367,7 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
|
|
||||||
# Persist every final block, including unchanged answers, for returns.
|
# Persist every final block, including unchanged answers, for returns.
|
||||||
dirty_images[label] = final_image
|
dirty_images[label] = final_image
|
||||||
|
header_heights[label] = new_header_height
|
||||||
answer_labels.append(label)
|
answer_labels.append(label)
|
||||||
concat_images.append(final_image)
|
concat_images.append(final_image)
|
||||||
|
|
||||||
@@ -335,8 +376,10 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
feedback.get("to_delete", False) for feedback in feedbacks
|
feedback.get("to_delete", False) for feedback in feedbacks
|
||||||
)
|
)
|
||||||
if not perfect or has_notes:
|
if not perfect or has_notes:
|
||||||
|
filtered_labels.append(label)
|
||||||
extras = (
|
extras = (
|
||||||
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels)
|
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels,
|
||||||
|
seen_contexts=seen_contexts)
|
||||||
if annotation_dir == "BGnot"
|
if annotation_dir == "BGnot"
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
@@ -346,8 +389,7 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
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.jpg", "Concat_F.pdf", "Concat_F.jpg",
|
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
|
||||||
"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
|
||||||
@@ -370,6 +412,9 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
[image for group in filtered_groups for image in group]
|
[image for group in filtered_groups for image in group]
|
||||||
)
|
)
|
||||||
filtered_image.save(staging / "Concat_F.jpg")
|
filtered_image.save(staging / "Concat_F.jpg")
|
||||||
|
stage_baseline(workspace.root, annotation_dir, output_dir, staging, dirty_images,
|
||||||
|
header_heights, build_answer_info(scores, labels_data, answer_labels, touched),
|
||||||
|
filtered_labels, scores)
|
||||||
if preserve_score_file:
|
if preserve_score_file:
|
||||||
logs.append(f" Preserved existing score.json in {output_dir}")
|
logs.append(f" Preserved existing score.json in {output_dir}")
|
||||||
logs.append(f" Saved regenerated files in {output_dir}")
|
logs.append(f" Saved regenerated files in {output_dir}")
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ COMMANDS: dict[str, Command] = {
|
|||||||
"import": Command("import_annotations", "Import handwritten annotations"),
|
"import": Command("import_annotations", "Import handwritten annotations"),
|
||||||
"read-annotations": Command("reading_annotations", "Read checkable annotations"),
|
"read-annotations": Command("reading_annotations", "Read checkable annotations"),
|
||||||
"read-grouped": Command("reading_grouped_annotations", "Read grouped annotations"),
|
"read-grouped": Command("reading_grouped_annotations", "Read grouped annotations"),
|
||||||
|
"modify-annotations": Command("modify_annotations", "Modify reviewed answer bodies"),
|
||||||
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
|
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
|
||||||
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
|
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
|
||||||
"add-final-score": Command("add_final_score", "Stamp final scores on copies"),
|
"add-final-score": Command("add_final_score", "Stamp final scores on copies"),
|
||||||
|
|||||||
@@ -92,3 +92,45 @@ def staged_files(
|
|||||||
if not destination_path.exists():
|
if not destination_path.exists():
|
||||||
saved.replace(destination_path)
|
saved.replace(destination_path)
|
||||||
_remove_path(backup)
|
_remove_path(backup)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_file_sets(file_sets: dict[Path, tuple[Path, Iterable[str]]]) -> None:
|
||||||
|
"""Commit several staged file sets together, rolling every set back on error.
|
||||||
|
|
||||||
|
Staging and destination directories must be on the same filesystem. Unlike
|
||||||
|
nesting staged_files, failure in a later directory restores earlier ones too.
|
||||||
|
"""
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
backups: list[tuple[Path, Path]] = []
|
||||||
|
committed: list[Path] = []
|
||||||
|
try:
|
||||||
|
for target, (staging, remove) in file_sets.items():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
backup = target / f".publish-{token}.backup"
|
||||||
|
backup.mkdir()
|
||||||
|
backups.append((target, backup))
|
||||||
|
staged = sorted(path for path in staging.iterdir() if path.is_file())
|
||||||
|
names = {path.name for path in staged} | set(remove)
|
||||||
|
for name in sorted(names):
|
||||||
|
if Path(name).name != name:
|
||||||
|
raise ValueError(f"Invalid output filename: {name}")
|
||||||
|
destination = target / name
|
||||||
|
if destination.exists() or destination.is_symlink():
|
||||||
|
destination.replace(backup / name)
|
||||||
|
for source in staged:
|
||||||
|
destination = target / source.name
|
||||||
|
source.replace(destination)
|
||||||
|
committed.append(destination)
|
||||||
|
except Exception:
|
||||||
|
for destination in reversed(committed):
|
||||||
|
_remove_path(destination)
|
||||||
|
for target, backup in reversed(backups):
|
||||||
|
for saved in backup.iterdir():
|
||||||
|
saved.replace(target / saved.name)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
for _, backup in backups:
|
||||||
|
if backup.exists() and not any(backup.iterdir()):
|
||||||
|
backup.rmdir()
|
||||||
|
for _, backup in backups:
|
||||||
|
_remove_path(backup)
|
||||||
+1
-1
@@ -70,7 +70,7 @@ def pdf_images_of_contexts(root_dir, label, all_labels):
|
|||||||
|
|
||||||
pertinent_contexts = []
|
pertinent_contexts = []
|
||||||
|
|
||||||
for filename in os.listdir(text2_dir):
|
for filename in sorted(os.listdir(text2_dir)):
|
||||||
if filename.startswith("CTXT ") and filename.endswith(".pdf"):
|
if filename.startswith("CTXT ") and filename.endswith(".pdf"):
|
||||||
# Extract "first_label -> last_label" from "CTXT first_label -> last_label.pdf"
|
# Extract "first_label -> last_label" from "CTXT first_label -> last_label.pdf"
|
||||||
core = filename[5:-4]
|
core = filename[5:-4]
|
||||||
|
|||||||
+21
-3
@@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel
|
|||||||
from .notifications import notify_desktop
|
from .notifications import notify_desktop
|
||||||
from .syncthing import SyncthingPanel
|
from .syncthing import SyncthingPanel
|
||||||
from .manual_resolution import ManualResolutionPanel
|
from .manual_resolution import ManualResolutionPanel
|
||||||
|
from .annotation_modifications import AnnotationModificationsPanel, annotation_mode_for_reader
|
||||||
from .recorrection import prepare_selection_file
|
from .recorrection import prepare_selection_file
|
||||||
from .refaire import SECTION as REFAIRE_SECTION
|
from .refaire import SECTION as REFAIRE_SECTION
|
||||||
from .refaire import (
|
from .refaire import (
|
||||||
@@ -824,6 +825,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
"labels",
|
"labels",
|
||||||
"plotting",
|
"plotting",
|
||||||
"manual_resolution",
|
"manual_resolution",
|
||||||
|
"modify_annotations",
|
||||||
"giving_names",
|
"giving_names",
|
||||||
"edit_scores",
|
"edit_scores",
|
||||||
"statement_persp",
|
"statement_persp",
|
||||||
@@ -888,6 +890,9 @@ class CopienatorApp(tk.Tk):
|
|||||||
value = False # Deletion is a one-run choice, never a saved default.
|
value = False # Deletion is a one-run choice, never a saved default.
|
||||||
if step.id == 'statement' and spec.name == 'restart':
|
if step.id == 'statement' and spec.name == 'restart':
|
||||||
value = False
|
value = False
|
||||||
|
if step.id == "modify_annotations" and spec.name == "annotation_dir" and spec.name not in values:
|
||||||
|
reader = self.state_store.step("read_annotations")
|
||||||
|
value = annotation_mode_for_reader(reader)
|
||||||
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)
|
||||||
@@ -939,12 +944,19 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
|
self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
if step.id == "manual_resolution":
|
if step.id in {"manual_resolution", "modify_annotations"}:
|
||||||
def manual_evaluation():
|
def manual_evaluation():
|
||||||
target = self.arg_vars.get("target")
|
target = self.arg_vars.get("target")
|
||||||
value = target.get().strip() if target else ""
|
value = target.get().strip() if target else ""
|
||||||
return (self.repository / value).resolve() if value else self.evaluation
|
return (self.repository / value).resolve() if value else self.evaluation
|
||||||
|
|
||||||
|
if step.id == "modify_annotations":
|
||||||
|
self.manual_panel = AnnotationModificationsPanel(
|
||||||
|
self.form, manual_evaluation,
|
||||||
|
lambda: self.arg_vars["annotation_dir"].get(),
|
||||||
|
)
|
||||||
|
self.arg_vars["annotation_dir"].trace_add("write", lambda *_: self.manual_panel.reload())
|
||||||
|
else:
|
||||||
self.manual_panel = ManualResolutionPanel(self.form, manual_evaluation)
|
self.manual_panel = ManualResolutionPanel(self.form, manual_evaluation)
|
||||||
self.manual_panel.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=8)
|
self.manual_panel.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=8)
|
||||||
if "target" in self.arg_vars:
|
if "target" in self.arg_vars:
|
||||||
@@ -966,7 +978,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
attach_tooltip(extra_entry, step.extra_arguments_help)
|
attach_tooltip(extra_entry, step.extra_arguments_help)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
|
self.run_button.configure(text="Make modifications" if step.id == "modify_annotations" else "Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
|
||||||
if step.id == "personal_create":
|
if step.id == "personal_create":
|
||||||
self.run_button.configure(text="Marquer Réussie")
|
self.run_button.configure(text="Marquer Réussie")
|
||||||
if step.id == "personal_read":
|
if step.id == "personal_read":
|
||||||
@@ -1953,7 +1965,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
self._append_console(f"Impossible de lancer la copie suivante : {exc}\n")
|
self._append_console(f"Impossible de lancer la copie suivante : {exc}\n")
|
||||||
status, return_code = "failed", 1
|
status, return_code = "failed", 1
|
||||||
self.pending_refaire_commands = []
|
self.pending_refaire_commands = []
|
||||||
if step_id == "refaire_merge" and status == "success":
|
if step_id in {"refaire_merge", "modify_annotations"} and status == "success":
|
||||||
for downstream in ("giving_names", "update_ods", "final_score", "personal_deploy", "personal_sent"):
|
for downstream in ("giving_names", "update_ods", "final_score", "personal_deploy", "personal_sent"):
|
||||||
entry = self.state_store.step(downstream)
|
entry = self.state_store.step(downstream)
|
||||||
if entry.get("status") in {"success", "detected", "skipped"}:
|
if entry.get("status") in {"success", "detected", "skipped"}:
|
||||||
@@ -1995,6 +2007,12 @@ class CopienatorApp(tk.Tk):
|
|||||||
"manual": False, "reason": reason,
|
"manual": False, "reason": reason,
|
||||||
})
|
})
|
||||||
self._append_console(f"Remplacer les barèmes par Gemini : ignorée — {reason}\n")
|
self._append_console(f"Remplacer les barèmes par Gemini : ignorée — {reason}\n")
|
||||||
|
if step_id == "read_annotations" and status == "success":
|
||||||
|
reader = self.state_store.step(step_id)
|
||||||
|
mode = annotation_mode_for_reader(reader)
|
||||||
|
values = dict(self.state_store.step("modify_annotations").get("values", {}))
|
||||||
|
values["annotation_dir"] = mode
|
||||||
|
self.state_store.update_step("modify_annotations", values=values)
|
||||||
if step_id == "annotation" and status == "success":
|
if step_id == "annotation" and status == "success":
|
||||||
generation_variant = self.state_store.step("annotation").get(
|
generation_variant = self.state_store.step("annotation").get(
|
||||||
"last_run_variant"
|
"last_run_variant"
|
||||||
|
|||||||
@@ -45,21 +45,29 @@ def cut_operator(percent: float, keep: int, mode: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class CutHelper(tk.Toplevel):
|
class CutHelper(tk.Toplevel):
|
||||||
def __init__(self, parent, path: Path, on_accept, initial=None):
|
def __init__(self, parent, path: Path | Image.Image, on_accept, initial=None):
|
||||||
# Load the source before creating a window so invalid PDFs leave no dialog.
|
# Body edits pass a raster without headers or supplementary pages.
|
||||||
|
width = min(850, parent.winfo_screenwidth() - 100)
|
||||||
|
if isinstance(path, Image.Image):
|
||||||
|
self.heights = [path.height]
|
||||||
|
self.scale = min(1.5, width / path.width)
|
||||||
|
rendered = [path.resize((round(path.width * self.scale),
|
||||||
|
round(path.height * self.scale)), Image.Resampling.LANCZOS)]
|
||||||
|
title = "Corps de la réponse annotée"
|
||||||
|
else:
|
||||||
with pymupdf.open(path) as document:
|
with pymupdf.open(path) as document:
|
||||||
if not len(document):
|
if not len(document):
|
||||||
raise ValueError("Le PDF est vide.")
|
raise ValueError("Le PDF est vide.")
|
||||||
self.heights = [page.rect.height for page in document]
|
self.heights = [page.rect.height for page in document]
|
||||||
width = min(850, parent.winfo_screenwidth() - 100)
|
|
||||||
self.scale = min(1.5, width / max(page.rect.width for page in document))
|
self.scale = min(1.5, width / max(page.rect.width for page in document))
|
||||||
rendered = []
|
rendered = []
|
||||||
for page in document:
|
for page in document:
|
||||||
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
|
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
|
||||||
colorspace=pymupdf.csRGB)
|
colorspace=pymupdf.csRGB)
|
||||||
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
|
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
|
||||||
|
title = path.name
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.title(f"Cut — {path.name}")
|
self.title(f"Cut — {title}")
|
||||||
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
|
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
|
||||||
self.transient(parent.winfo_toplevel())
|
self.transient(parent.winfo_toplevel())
|
||||||
self.on_accept = on_accept
|
self.on_accept = on_accept
|
||||||
@@ -125,7 +133,7 @@ class CutHelper(tk.Toplevel):
|
|||||||
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
|
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
|
||||||
rounded = float(operator.split("{")[1].split("}")[0])
|
rounded = float(operator.split("{")[1].split("}")[0])
|
||||||
if not 0 < rounded < 100:
|
if not 0 < rounded < 100:
|
||||||
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self)
|
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion de la réponse.", parent=self)
|
||||||
return
|
return
|
||||||
self.destroy()
|
self.destroy()
|
||||||
self.on_accept(operator)
|
self.on_accept(operator)
|
||||||
@@ -37,9 +37,10 @@ Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter
|
|||||||
|
|
||||||
|
|
||||||
class ManualResolutionPanel(ttk.Frame):
|
class ManualResolutionPanel(ttk.Frame):
|
||||||
def __init__(self, parent, get_evaluation):
|
def __init__(self, parent, get_evaluation, *, help_text=HELP, show_provenance=True):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.get_evaluation = get_evaluation
|
self.get_evaluation = get_evaluation
|
||||||
|
self.show_provenance = show_provenance
|
||||||
self.pdf_buttons = []
|
self.pdf_buttons = []
|
||||||
self.cut_buttons = []
|
self.cut_buttons = []
|
||||||
self.copy_buttons = []
|
self.copy_buttons = []
|
||||||
@@ -67,7 +68,7 @@ class ManualResolutionPanel(ttk.Frame):
|
|||||||
self.text.configure(yscrollcommand=scroll.set)
|
self.text.configure(yscrollcommand=scroll.set)
|
||||||
scroll.pack(side="right", fill="y")
|
scroll.pack(side="right", fill="y")
|
||||||
self.text.pack(fill="both", expand=True)
|
self.text.pack(fill="both", expand=True)
|
||||||
ttk.Label(self, text=HELP, wraplength=650, justify="left").pack(fill="x", pady=8)
|
ttk.Label(self, text=help_text, wraplength=650, justify="left").pack(fill="x", pady=8)
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
||||||
def manual_path(self) -> Path | None:
|
def manual_path(self) -> Path | None:
|
||||||
@@ -123,7 +124,7 @@ class ManualResolutionPanel(ttk.Frame):
|
|||||||
raise FileNotFoundError("Chargez une évaluation.")
|
raise FileNotFoundError("Chargez une évaluation.")
|
||||||
content = path.read_text(encoding="utf-8")
|
content = path.read_text(encoding="utf-8")
|
||||||
except (OSError, UnicodeError) as exc:
|
except (OSError, UnicodeError) as exc:
|
||||||
self.status.configure(text=f"manual_resolutions.txt indisponible : {exc}")
|
self.status.configure(text=f"{path.name if path else 'Fichier'} indisponible : {exc}")
|
||||||
else:
|
else:
|
||||||
self.editor_button.configure(state="normal")
|
self.editor_button.configure(state="normal")
|
||||||
malformed = []
|
malformed = []
|
||||||
@@ -142,7 +143,7 @@ class ManualResolutionPanel(ttk.Frame):
|
|||||||
key = (instruction.copy_id, instruction.old_label)
|
key = (instruction.copy_id, instruction.old_label)
|
||||||
if key not in provenance:
|
if key not in provenance:
|
||||||
try:
|
try:
|
||||||
provenance[key] = populated_labels(path.parent, *key)
|
provenance[key] = populated_labels(path.parent, *key) if self.show_provenance else []
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
provenance[key] = []
|
provenance[key] = []
|
||||||
provenance_error = f" — Provenance indisponible : {exc}"
|
provenance_error = f" — Provenance indisponible : {exc}"
|
||||||
|
|||||||
@@ -668,6 +668,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
),
|
),
|
||||||
) + return_answer_arguments,
|
) + return_answer_arguments,
|
||||||
),
|
),
|
||||||
|
StepDefinition(
|
||||||
|
"modify_annotations",
|
||||||
|
"Finalisation",
|
||||||
|
"Modifier les réponses annotées",
|
||||||
|
"Coupez ou transférez les corps de réponses après lecture des annotations. "
|
||||||
|
"Les en-têtes, les scores et les documents complémentaires restent associés à chaque label.",
|
||||||
|
(python("default", "Modifications", "modify-annotations"),),
|
||||||
|
arguments=(
|
||||||
|
arg_target("le dossier de l’évaluation"),
|
||||||
|
ArgumentSpec("annotation_dir", "Dossier d’annotations", "choice", "--annotation-dir",
|
||||||
|
default="BGnot", choices=("BGnot", "Bnot"),
|
||||||
|
help="Choisissez le mode utilisé pour lire les annotations manuscrites ; les coupes portent uniquement sur les corps des réponses."),
|
||||||
|
),
|
||||||
|
optional=True,
|
||||||
|
requires=("labels",),
|
||||||
|
artifacts=("annotation_modifications.txt",),
|
||||||
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"giving_names",
|
"giving_names",
|
||||||
"Finalisation",
|
"Finalisation",
|
||||||
@@ -778,15 +795,6 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
(manual("deploy"),),
|
(manual("deploy"),),
|
||||||
personal=True,
|
personal=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
|
||||||
"personal_print",
|
|
||||||
"Étapes personnelles",
|
|
||||||
"Imprimer une copie",
|
|
||||||
"Étape manuelle via le lecteur PDF.",
|
|
||||||
(manual("print"),),
|
|
||||||
personal=True,
|
|
||||||
optional=True,
|
|
||||||
),
|
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"clean",
|
"clean",
|
||||||
"Archivage",
|
"Archivage",
|
||||||
|
|||||||
+41
-6
@@ -177,17 +177,22 @@ For each retained answer, the PDF stacks the following available material:
|
|||||||
3. The model solution from `Sol2/<label>.pdf`.
|
3. The model solution from `Sol2/<label>.pdf`.
|
||||||
4. The same compiled answer block used for the JPEG.
|
4. The same compiled answer block used for the JPEG.
|
||||||
|
|
||||||
Missing supplementary PDFs are skipped. Contexts can repeat for successive
|
Missing supplementary PDFs are skipped. Each distinct context appears only once
|
||||||
questions. Each question's complete group stays together on one page; groups
|
per student PDF, before its first retained question. Duplicate detection uses
|
||||||
|
extracted PDF text with Unicode and whitespace normalization, including contexts
|
||||||
|
stored under different filenames or validity ranges. Numbers, mathematical
|
||||||
|
symbols and case remain significant. Contexts without extractable text use a
|
||||||
|
PDF-content hash instead, so unrelated image-only contexts are not collapsed.
|
||||||
|
The same rule applies to selective redo and manual annotation modifications.
|
||||||
|
Each question's complete group stays together on one page; groups
|
||||||
are packed until the next would exceed the target height. An oversized group
|
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
|
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.
|
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
|
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
|
margins are added on the left and above/below the page. The question receives
|
||||||
group receives a black border and the second a blue border. These borders are
|
a black border and the model solution a blue border. Context and student answer
|
||||||
assigned by position, so they do not consistently identify question and solution
|
blocks receive neither border, including when supplementary files are missing.
|
||||||
when contexts are present or supplementary files are missing.
|
|
||||||
|
|
||||||
If nothing survives filtering, `read-grouped` removes old `Concat_F` outputs and
|
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
|
does not create a new PDF. During a selective `--refaire` merge, saved answer
|
||||||
@@ -256,3 +261,33 @@ ones. Weighting and the final overall mark belong to the separate grading flow.
|
|||||||
- [ODS export](../copienator/commands/update_ods.py)
|
- [ODS export](../copienator/commands/update_ods.py)
|
||||||
- [Final-mark stamping](../copienator/commands/add_final_score.py)
|
- [Final-mark stamping](../copienator/commands/add_final_score.py)
|
||||||
- [Cleanup retention](../copienator/commands/clean.py)
|
- [Cleanup retention](../copienator/commands/clean.py)
|
||||||
|
|
||||||
|
|
||||||
|
## Manual modifications after annotation reading
|
||||||
|
|
||||||
|
`modify-annotations EVALUATION --annotation-dir BGnot` (or `Bnot`) applies
|
||||||
|
`annotation_modifications.txt`. The GUI exposes it between annotation reading
|
||||||
|
and return preparation. Its `### Source: BGnot`/`Bnot` line must match the mode.
|
||||||
|
Instruction operators follow manual resolution syntax but act on reviewed raster
|
||||||
|
**bodies**, excluding headers and all supplementary pages. Headers and scores stay
|
||||||
|
with their labels. Missing target bodies are allowed; their new header identifies
|
||||||
|
the target and its existing score, without inventing feedback or a score.
|
||||||
|
|
||||||
|
Both readers record `annotation_baseline.json` and lossless hidden
|
||||||
|
`.annotation-base-*.png` images in each copy's annotation directory. The JSON
|
||||||
|
stores the exact header height, baseline answer metadata, and filtered membership.
|
||||||
|
Older outputs require one full annotation read before body editing. Legacy
|
||||||
|
selective redo still works, but cannot infer missing header boundaries.
|
||||||
|
|
||||||
|
Each application rebuilds from that baseline and publishes all affected copies
|
||||||
|
and the mode's `annotation_modifications_applied.txt` in one rollback-capable
|
||||||
|
transaction. Reapplying is idempotent; removing an instruction restores its prior
|
||||||
|
content and membership. Annotation rereads refresh the baseline and replay the
|
||||||
|
applied snapshot, not the draft. Selective redo preserves unaffected baseline
|
||||||
|
layers so previous transfers are never included twice.
|
||||||
|
|
||||||
|
The operation rebuilds per-label JPEGs, `info.json`, `Concat.jpg`, and the filtered
|
||||||
|
BGnot PDF or Bnot JPEG. Every changed non-empty body is included in the filtered
|
||||||
|
output, with its destination label's supplementary material. `score.json` is not
|
||||||
|
modified. The GUI marks downstream return preparation stale. Rerun `giving-names`
|
||||||
|
without `--update` to refresh both full outputs and individual answer exports.
|
||||||
@@ -368,6 +368,56 @@ score.json** sert à mettre à jour les scores sur les images finales à
|
|||||||
partir d'une modification manuelle des fichiers `score.json`.
|
partir d'une modification manuelle des fichiers `score.json`.
|
||||||
|
|
||||||
|
|
||||||
|
### Modifier les réponses annotées
|
||||||
|
|
||||||
|
Cette étape facultative se trouve après la lecture des annotations, pour **Bnot**
|
||||||
|
et **BGnot**. Pour une ancienne évaluation, relancez d’abord **Lire les annotations
|
||||||
|
manuscrites** : cette lecture enregistre maintenant la limite exacte de chaque
|
||||||
|
en-tête et une référence sans perte des images annotées.
|
||||||
|
|
||||||
|
Saisissez la **Copie**, le **Label source** et le **Label cible**, puis **Ajouter**.
|
||||||
|
Les deux labels doivent exister ; la source doit avoir une réponse annotée. Une
|
||||||
|
cible sans réponse est acceptée. Vous pouvez ajouter plusieurs instructions.
|
||||||
|
|
||||||
|
Les boutons **Source**, **Cible** et **Cut** montrent uniquement le **corps de la
|
||||||
|
réponse** : copie originale, annotations Gemini conservées et annotations
|
||||||
|
manuscrites situées sur ce corps. Ils excluent le score, les commentaires globaux
|
||||||
|
de l’en-tête, le contexte, l’énoncé et le corrigé. Les annotations manuscrites de
|
||||||
|
l’en-tête restent dans leur en-tête. Le pourcentage de coupe porte sur la hauteur
|
||||||
|
du corps uniquement. Aucun fragment n’est redimensionné lors des transferts.
|
||||||
|
|
||||||
|
Les instructions sont enregistrées dans `annotation_modifications.txt` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
### Source: BGnot
|
||||||
|
Copie43 Ex 1 c{43}1> Ex 2|
|
||||||
|
```
|
||||||
|
|
||||||
|
Cet exemple conserve le début de la source (43 %) et ajoute la fin après le corps
|
||||||
|
de la cible. `2` conserve la fin, `x` remplace le corps cible au lieu d’ajouter, et
|
||||||
|
`|Ex 2` place le fragment avant la cible. Les opérateurs sans coupe de la résolution
|
||||||
|
manuelle sont aussi disponibles et décrits dans le panneau. Une instruction par
|
||||||
|
source ; une cible ne peut pas être source dans le même fichier. Plusieurs sources
|
||||||
|
peuvent alimenter une cible, dans l’ordre du fichier.
|
||||||
|
|
||||||
|
**Cut** prépare une commande à recopier. Utilisez **Ouvrir dans un éditeur de
|
||||||
|
texte**, enregistrez puis **Recharger**. **Aperçu du résultat** montre les corps
|
||||||
|
résultants pour la copie saisie. Cliquez sur **Make modifications** pour appliquer
|
||||||
|
le fichier enregistré. Chaque label conserve son en-tête ; les scores restent
|
||||||
|
inchangés. Les images individuelles, les images combinées, le PDF filtré BGnot
|
||||||
|
(ou l’image filtrée Bnot) et les informations de présence sont reconstruits.
|
||||||
|
Les réponses modifiées non vides sont incluses dans le résultat filtré, avec les
|
||||||
|
compléments propres à leur label.
|
||||||
|
|
||||||
|
Le fichier reste éditable après application. Réappliquer ne cumule pas les
|
||||||
|
transferts : le traitement repart de la référence issue de la lecture des
|
||||||
|
annotations. Retirer une ligne puis appliquer annule son effet. Une nouvelle
|
||||||
|
lecture des annotations renouvelle la référence et réapplique les dernières
|
||||||
|
instructions **appliquées**, sans prendre en compte un brouillon non appliqué.
|
||||||
|
|
||||||
|
Relancez ensuite **Attribuer les noms et préparer A Rendre**, sans cocher l’option
|
||||||
|
limitée aux images `answers`, afin d’actualiser tous les livrables.
|
||||||
|
|
||||||
### Attribuer les noms et préparer A Rendre
|
### Attribuer les noms et préparer A Rendre
|
||||||
|
|
||||||
**Effet :** rassemble les résultats de chaque élève dans le dossier `A Rendre`.
|
**Effet :** rassemble les résultats de chaque élève dans le dossier `A Rendre`.
|
||||||
|
|||||||
Reference in new issue
Block a user