diff --git a/copienator/commands/reading_annotations.py b/copienator/commands/reading_annotations.py index 92898ad..3b2cccd 100644 --- a/copienator/commands/reading_annotations.py +++ b/copienator/commands/reading_annotations.py @@ -9,20 +9,26 @@ import numpy as np from pdf2image import convert_from_path from PIL import Image, ImageChops, ImageDraw, ImageFilter -from copienator.commands import annotating -from copienator import configuration, utils from copienator import ( EvaluationWorkspace, ExitCode, atomic_write_json, + configuration, evaluation_parser, execute, read_json, + utils, workspace_from_args, ) 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_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.return_answers import save_return_answer_options @@ -168,6 +174,8 @@ def apply_actions_and_regenerate( scores = dict.fromkeys(all_labels, "") answer_labels: list[str] = [] dirty_images: dict[str, Image.Image] = {} + header_heights: dict[str, int] = {} + filtered_labels: list[str] = [] concatenated: list[Image.Image] = [] filtered: list[Image.Image] = [] incomplete = False @@ -221,15 +229,18 @@ def apply_actions_and_regenerate( final_image.paste(body, (0, new_header_height), mask=body) dirty_images[label] = final_image + header_heights[label] = new_header_height answer_labels.append(label) concatenated.append(final_image) if float(scores[label]) != 4.0 or result.get("feedback", []): filtered.append(final_image) + filtered_labels.append(label) + if incomplete: + return ExitCode.PARTIAL concat_image = concatenate(concatenated) filtered_image = concatenate(filtered) - with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", - "touched.json", "answer_labels.json")) as staging: + with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging: for label, image in dirty_images.items(): image.save(staging / f"{label}.jpg") if not preserve_score_file: @@ -241,6 +252,9 @@ def apply_actions_and_regenerate( concat_image.save(staging / "Concat.jpg") if filtered_image is not None: 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: print(f" Preserved existing score.json in {output_dir}") diff --git a/copienator/commands/reading_grouped_annotations.py b/copienator/commands/reading_grouped_annotations.py index 141b9aa..c5eb5bf 100644 --- a/copienator/commands/reading_grouped_annotations.py +++ b/copienator/commands/reading_grouped_annotations.py @@ -9,11 +9,11 @@ from typing import Any from PIL import Image, ImageDraw -from copienator import configuration from copienator import ( EvaluationWorkspace, ExitCode, atomic_write_json, + configuration, evaluation_parser, execute, read_json, @@ -21,8 +21,15 @@ from copienator import ( workspace_from_args, ) 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_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.reading_annotations import ( concatenate, @@ -247,6 +254,9 @@ def apply_actions_and_regenerate_grouped( touched = dict.fromkeys(all_labels, False) answer_labels: list[str] = [] 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] = [] filtered_groups: list[list[Image.Image]] = [] incomplete = False @@ -266,9 +276,19 @@ def apply_actions_and_regenerate_grouped( if result.get("error") == "empty-answer": continue saved_image = output_dir / f"{label}.jpg" - if selected_labels and label not in selected_labels and saved_image.is_file(): - with Image.open(saved_image) as saved: - final_image = saved.convert("RGB").copy() + 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: + final_image = saved.convert("RGB").copy() if ( simple_annotated is not None and label in simple_layout["images"] @@ -278,10 +298,11 @@ def apply_actions_and_regenerate_grouped( final_image = simple_annotated.crop( (0, hmin, simple_annotated.width, hmax) ) - dirty_images[label] = final_image + dirty_images[label] = final_image scores[label] = str(old_scores.get(label, scores[label])) concat_images.append(final_image) answer_labels.append(label) + filtered_labels.append(label) # Keep previously reviewed content, including handwriting. if annotation_dir == "BGnot": extras = get_extra_pdfs_as_images( @@ -327,6 +348,7 @@ def apply_actions_and_regenerate_grouped( # Persist every final block, including unchanged answers, for returns. dirty_images[label] = final_image + header_heights[label] = new_header_height answer_labels.append(label) concat_images.append(final_image) @@ -335,6 +357,7 @@ def apply_actions_and_regenerate_grouped( feedback.get("to_delete", False) for feedback in feedbacks ) if not perfect or has_notes: + filtered_labels.append(label) extras = ( get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels) if annotation_dir == "BGnot" @@ -346,8 +369,7 @@ def apply_actions_and_regenerate_grouped( concat_image = concatenate(concat_images) if incomplete: return ExitCode.PARTIAL, "\n".join(logs) - with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.pdf", "Concat_F.jpg", - "touched.json", "answer_labels.json")) as staging: + with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging: if simple_layout is not None: simple_layout["replaced"] = sorted( set(simple_layout["replaced"]) | selected_labels @@ -370,6 +392,9 @@ def apply_actions_and_regenerate_grouped( [image for group in filtered_groups for image in group] ) 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: logs.append(f" Preserved existing score.json in {output_dir}") logs.append(f" Saved regenerated files in {output_dir}") diff --git a/copienator/dispatcher.py b/copienator/dispatcher.py index 1328838..4ffdfe0 100644 --- a/copienator/dispatcher.py +++ b/copienator/dispatcher.py @@ -40,6 +40,7 @@ COMMANDS: dict[str, Command] = { "import": Command("import_annotations", "Import handwritten annotations"), "read-annotations": Command("reading_annotations", "Read checkable 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"), "update-ods": Command("update_ods", "Update the configured score spreadsheet"), "add-final-score": Command("add_final_score", "Stamp final scores on copies"), diff --git a/copienator/filesystem.py b/copienator/filesystem.py index e5331af..e067113 100644 --- a/copienator/filesystem.py +++ b/copienator/filesystem.py @@ -92,3 +92,45 @@ def staged_files( if not destination_path.exists(): saved.replace(destination_path) _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) diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 8b88832..12be104 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel from .notifications import notify_desktop from .syncthing import SyncthingPanel from .manual_resolution import ManualResolutionPanel +from .annotation_modifications import AnnotationModificationsPanel, annotation_mode_for_reader from .recorrection import prepare_selection_file from .refaire import SECTION as REFAIRE_SECTION from .refaire import ( @@ -824,6 +825,7 @@ class CopienatorApp(tk.Tk): "labels", "plotting", "manual_resolution", + "modify_annotations", "giving_names", "edit_scores", "statement_persp", @@ -888,6 +890,9 @@ class CopienatorApp(tk.Tk): value = False # Deletion is a one-run choice, never a saved default. if step.id == 'statement' and spec.name == 'restart': 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 if spec.name == "annotation_dir" and step.id in {"export", "import"}: choices, value = self._annotation_directory_choices(step.id) @@ -939,13 +944,20 @@ class CopienatorApp(tk.Tk): self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8) row += 1 - if step.id == "manual_resolution": + if step.id in {"manual_resolution", "modify_annotations"}: def manual_evaluation(): target = self.arg_vars.get("target") value = target.get().strip() if target else "" return (self.repository / value).resolve() if value else self.evaluation - self.manual_panel = ManualResolutionPanel(self.form, manual_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.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=8) if "target" in self.arg_vars: self.arg_vars["target"].trace_add("write", lambda *_args: self.manual_panel.reload()) @@ -966,7 +978,7 @@ class CopienatorApp(tk.Tk): attach_tooltip(extra_entry, step.extra_arguments_help) 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": self.run_button.configure(text="Marquer Réussie") 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") status, return_code = "failed", 1 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"): entry = self.state_store.step(downstream) if entry.get("status") in {"success", "detected", "skipped"}: @@ -1995,6 +2007,12 @@ class CopienatorApp(tk.Tk): "manual": False, "reason": reason, }) 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": generation_variant = self.state_store.step("annotation").get( "last_run_variant" diff --git a/copienator_gui/cut_helper.py b/copienator_gui/cut_helper.py index 10ea2f1..3fa867a 100644 --- a/copienator_gui/cut_helper.py +++ b/copienator_gui/cut_helper.py @@ -45,21 +45,29 @@ def cut_operator(percent: float, keep: int, mode: str) -> str: class CutHelper(tk.Toplevel): - def __init__(self, parent, path: Path, on_accept, initial=None): - # Load the source before creating a window so invalid PDFs leave no dialog. - with pymupdf.open(path) as document: - if not len(document): - raise ValueError("Le PDF est vide.") - 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)) - rendered = [] - for page in document: - pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False, - colorspace=pymupdf.csRGB) - rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples)) + def __init__(self, parent, path: Path | Image.Image, on_accept, initial=None): + # 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: + if not len(document): + raise ValueError("Le PDF est vide.") + self.heights = [page.rect.height for page in document] + self.scale = min(1.5, width / max(page.rect.width for page in document)) + rendered = [] + for page in document: + pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False, + colorspace=pymupdf.csRGB) + rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples)) + title = path.name 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.transient(parent.winfo_toplevel()) self.on_accept = on_accept @@ -125,7 +133,7 @@ class CutHelper(tk.Toplevel): operator = cut_operator(self.percent, self.keep.get(), self.mode.get()) rounded = float(operator.split("{")[1].split("}")[0]) 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 self.destroy() self.on_accept(operator) diff --git a/copienator_gui/manual_resolution.py b/copienator_gui/manual_resolution.py index 6f5c4f8..3baf669 100644 --- a/copienator_gui/manual_resolution.py +++ b/copienator_gui/manual_resolution.py @@ -37,9 +37,10 @@ Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter 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) self.get_evaluation = get_evaluation + self.show_provenance = show_provenance self.pdf_buttons = [] self.cut_buttons = [] self.copy_buttons = [] @@ -67,7 +68,7 @@ class ManualResolutionPanel(ttk.Frame): self.text.configure(yscrollcommand=scroll.set) scroll.pack(side="right", fill="y") 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() def manual_path(self) -> Path | None: @@ -123,7 +124,7 @@ class ManualResolutionPanel(ttk.Frame): raise FileNotFoundError("Chargez une évaluation.") content = path.read_text(encoding="utf-8") 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: self.editor_button.configure(state="normal") malformed = [] @@ -142,7 +143,7 @@ class ManualResolutionPanel(ttk.Frame): key = (instruction.copy_id, instruction.old_label) if key not in provenance: 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: provenance[key] = [] provenance_error = f" — Provenance indisponible : {exc}" diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 3d4d62a..274d4f3 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -668,6 +668,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ), ) + 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( "giving_names", "Finalisation", diff --git a/docs/final_output.md b/docs/final_output.md index 90501ed..40861c4 100644 --- a/docs/final_output.md +++ b/docs/final_output.md @@ -256,3 +256,33 @@ ones. Weighting and the final overall mark belong to the separate grading flow. - [ODS export](../copienator/commands/update_ods.py) - [Final-mark stamping](../copienator/commands/add_final_score.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. diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index c667771..e41cbe9 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -368,6 +368,56 @@ score.json** sert à mettre à jour les scores sur les images finales à 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 **Effet :** rassemble les résultats de chaque élève dans le dossier `A Rendre`.