diff --git a/copienator/commands/cutleft.py b/copienator/commands/cutleft.py index 54044c4..69543f2 100644 --- a/copienator/commands/cutleft.py +++ b/copienator/commands/cutleft.py @@ -284,6 +284,7 @@ class ImageReviewer: self.current_width_offset = 0 self.default_max_per_file = default_max_per_file self.current_max_per_file = default_max_per_file + self.review_settings: dict[int, tuple[int, int, int]] = {} self.current_preview: Image.Image | None = None self.is_processing = False self.manual_queue: Queue[ @@ -298,6 +299,8 @@ class ImageReviewer: self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold")) self.label_info.pack(pady=5) self.root.bind("", self.on_next) + self.root.bind("p", self.on_previous) + self.root.bind("P", self.on_previous) self.root.bind("!", self.on_skip) self.root.protocol("WM_DELETE_WINDOW", self.on_close) self.root.bind("", lambda _event: self.on_shift(-CROP_SHIFT_STEP)) @@ -343,8 +346,9 @@ class ImageReviewer: self.on_close() return self.is_processing = False - self.current_shift = 0 - self.current_width_offset = 0 + self.current_shift, self.current_width_offset, self.current_max_per_file = ( + self.review_settings.get(self.index, (0, 0, self.default_max_per_file)) + ) self.current_result = None self.margin_analysis = None self.trigger_processing(self.files[self.index], self.current_shift) @@ -426,7 +430,7 @@ class ImageReviewer: f"Cols: {schema['columns_per_file']}\n" f"{margin_info}" "Arrows: left/right move by 50px | up/down widen/narrow by 50px\n" - "Enter: Save and next | !: flag error and skip | 1: use full pages" + "Enter: Save and next | P/p: previous copy | !: flag error and skip | 1: use full pages" f"{personal_instructions}" ), fg="black", @@ -474,11 +478,21 @@ class ImageReviewer: self.stop_prefetch.set() self.root.destroy() + def _remember_settings(self) -> None: + self.review_settings[self.index] = ( + self.current_shift, self.current_width_offset, self.current_max_per_file, + ) + + def on_previous(self, _event=None) -> None: + if self.is_processing or self.index == 0: + return + self._remember_settings() + self.index -= 1 + self.load_current_image() + def _advance(self) -> None: + self._remember_settings() self.index += 1 - self.current_shift = 0 - self.current_width_offset = 0 - self.current_max_per_file = self.default_max_per_file self.load_current_image() diff --git a/copienator/commands/enonce_info.py b/copienator/commands/enonce_info.py index a2aca57..9dfb44c 100644 --- a/copienator/commands/enonce_info.py +++ b/copienator/commands/enonce_info.py @@ -32,7 +32,7 @@ def fetch_and_save_sub_text(ex_id, indices, label, text_path): try: with urllib.request.urlopen(url) as response: content = response.read().decode('utf-8') - content = replace_dots(content.strip("\n")) + content = prepare_latex_fragment(content.strip("\n")) with open(os.path.join(text_path, f"{label}.tex"), 'w', encoding='utf-8') as f: f.write(content) # Compile PDF @@ -52,7 +52,7 @@ def fetch_and_save_sub_sol(ex_id, indices, label, sol_path): try: with urllib.request.urlopen(url) as response: content = response.read().decode('utf-8') - content = replace_dots(content.strip("\n")) + content = prepare_latex_fragment(content.strip("\n")) with open(os.path.join(sol_path, f"{label}.tex"), 'w', encoding='utf-8') as f: f.write(content) # Compile PDF @@ -70,13 +70,39 @@ def replace_dots(text): # (?m) enables multiline mode so ^ matches start of each line return re.sub(r"(?m)^(\s*.)\.", r"\1)", text) -def replace_problem_labels(text): + +def prepare_latex_fragment(text): + """Keep counter-only extracted lists valid before an enumitem resume.""" + # Extraction can omit every item before a context block while retaining + # the list and its counters for resume*. An unnumbered item makes that + # empty list legal without advancing the resumed question number. + return re.sub( + r'(\\begin\{enumerate\}(?:\[[^\]\n]*\])?)' + r'(\s*(?:\\stepcounter\{[^{}]+\}\s*)*)' + r'(\\end\{enumerate\})', + r'\1\\item[]\2\3', + replace_dots(text), + ) + +def replace_problem_labels(text, numbering="problem"): """Replaces labels according to spaces depth when problem=True.""" def repl(m): spaces = m.group(1) label = m.group(2) n = len(spaces) try: + if numbering == "problem_abc": + if n == 1 and label.isdigit(): + return f"{spaces}{ROMANS_CAP[int(label)]})" + if n == 4 and label.isalpha(): + return f"{spaces}{label.upper()})" + if n == 7 and label.isdigit(): + return m.group(0) + if n == 10 and label.isdigit(): + return f"{spaces}{chr(96 + int(label))})" + if n == 13 and label.isdigit(): + return f"{spaces}{ROMANS_LOW[int(label)]})" + return m.group(0) if n == 1 and label.isdigit(): # 1 space: 1) -> I) return f"{spaces}{ROMANS_CAP[int(label)]})" elif n == 4 and label.isalpha(): # 4 spaces: a) -> 1) @@ -92,7 +118,17 @@ def replace_problem_labels(text): # Matches start of line, spaces, alphanumeric label, and closing parenthesis return re.sub(r"(?m)^([ \t]+)([a-zA-Z0-9]+)\)", repl, text) -def format_indices(indices, problem=False): +def format_indices(indices, problem=False, *, numbering=None): + if numbering is not None: + if numbering not in {"exercise", "problem", "problem_abc"}: + raise ValueError(f"Unknown SHEETINFO numbering scheme: {numbering!r}") + problem = numbering != "exercise" + if numbering == "problem_abc": + formatters = (lambda n: ROMANS_CAP[n], lambda n: chr(64 + n), str, + lambda n: chr(96 + n), lambda n: ROMANS_LOW[n]) + if len(indices) > len(formatters): + raise ValueError("Too many question levels for problem_abc numbering") + return "".join(formatter(index) + ")" for formatter, index in zip(formatters, indices)) if not indices: return "" if not problem: res = f"{indices[0]})" @@ -200,6 +236,11 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode: try: data = json.loads(json_str) + numbering = data.get('numbering') + if numbering is not None: + # Validate even exercises without question indexes. + format_indices([], numbering=numbering) + problem = numbering != 'exercise' block_labels = [] # Construct 'ids' parameter ex_id = str(data['id']) @@ -222,7 +263,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode: fetch_and_save_sub_sol(ids, [], label, paths['Sol2']) else: for item in indexes: - suffix = format_indices(item['indices'], problem) + suffix = format_indices(item['indices'], problem, numbering=numbering) label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "") validate_windows_labels([label]) block_labels.append(label) @@ -252,9 +293,9 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode: # Apply hierarchy depth replace if problem context if problem: - t_text = replace_problem_labels(t_text) - s_text = replace_problem_labels(s_text) - p_text = replace_problem_labels(p_text) + t_text = replace_problem_labels(t_text, numbering or 'problem') + s_text = replace_problem_labels(s_text, numbering or 'problem') + p_text = replace_problem_labels(p_text, numbering or 'problem') base_filename = f"Ex {current_ex_num}" diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index e189d1a..dc6e4ab 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -381,6 +381,9 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non ) groups = LabelGroups.model_validate_json(response.text).groups validate_groups(groups, labels) + label_order = {label: index for index, label in enumerate(labels)} + groups = [sorted(group, key=label_order.__getitem__) for group in groups] + groups.sort(key=lambda group: label_order[group[0]]) atomic_write_text(workspace.label_groups_file, "".join(", ".join(group) + "\n" for group in groups)) print(f"Updated label_groups: {len(groups)} Gemini groups.") diff --git a/copienator/commands/page_splitter.py b/copienator/commands/page_splitter.py index d0c7f85..f91878e 100644 --- a/copienator/commands/page_splitter.py +++ b/copienator/commands/page_splitter.py @@ -41,9 +41,13 @@ PAGE_SPLITTER_KB = { # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) CM_TO_POINTS = (1 / 2.54) * 72 -def list_pdf_files(directory: str | Path) -> list[Path]: +def list_pdf_files( + directory: str | Path, workspace: EvaluationWorkspace | None = None, +) -> list[Path]: + workspace = workspace or EvaluationWorkspace(Path(directory)) paths = sorted(Path(directory).glob("*.pdf"), key=lambda path: path.name.casefold()) - return [path for path in paths if "enonce" not in path.name.casefold()] + return [path for path in paths if "enonce" not in path.name.casefold() + and not workspace.is_statement_pdf(workspace.root / path.name)] def _temporary_sibling(path: Path, purpose: str) -> Path: @@ -177,7 +181,7 @@ class PDFPreviewer: f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n" f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n" f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n" - f"{fmt('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; orange = active)\n" + f"{fmt('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; main line red, active secondary orange)\n" f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n" ) @@ -302,7 +306,7 @@ class PDFPreviewer: # The line position is scaled by the same zoom factor line_x_scaled = self.current_line_x * self.current_zoom draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], - fill="red" if self.secondary_splitters else "orange", width=3) + fill="red", width=3) for index, position in enumerate(self.secondary_splitters): x = position * self.current_zoom active = index == len(self.secondary_splitters) - 1 @@ -664,12 +668,14 @@ def _selected_inputs( if target.is_file(): if target.suffix.casefold() != ".pdf": raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS) + if workspace.is_statement_pdf(workspace.root / target.name): + raise CliError(f"Target is the statement PDF, not a copy: {target}", ExitCode.INVALID_ARGUMENTS) backup = workspace.original_copies_dir / target.name return [backup if backup.is_file() else target] directory = target if target == workspace.copies_dir: - candidates = list_pdf_files(workspace.copies_dir) + candidates = list_pdf_files(workspace.copies_dir, workspace) candidates = [ ( workspace.original_copies_dir / path.name @@ -679,7 +685,7 @@ def _selected_inputs( for path in candidates ] else: - candidates = list_pdf_files(directory) + candidates = list_pdf_files(directory, workspace) return list(reversed(candidates)) diff --git a/copienator/commands/plotting.py b/copienator/commands/plotting.py index 4f949f1..6194e2d 100644 --- a/copienator/commands/plotting.py +++ b/copienator/commands/plotting.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import copy +import json import queue import re import threading @@ -16,6 +17,7 @@ from copienator import ( EvaluationWorkspace, ExitCode, atomic_write_json, + atomic_write_text, execute, read_json, target_parser, @@ -31,6 +33,31 @@ MISSING_LABEL_COLOR = "orange" COMMON_MISSING_LABEL_COLOR = "#403a00" COMMON_MISSING_THRESHOLD = 0.66 + +def prepare_label_json_for_edit(path: Path) -> None: + """Leave a trailing comma after the last label so another can be pasted.""" + data = read_json(path, repair_trailing_commas=True) + if not isinstance(data, dict) or not isinstance(data.get("list"), list) or not data["list"]: + return + text = path.read_text(encoding="utf-8") + decoder = json.JSONDecoder() + whitespace = re.compile(r"\s*") + skip_space = lambda position: whitespace.match(text, position).end() + cursor = skip_space(text.index("{") + 1) + # Decode top-level values to locate this list without matching delimiters + # inside strings or nested bounding-box arrays. Keep the existing layout. + while text[cursor] != "}": + key, end = decoder.raw_decode(text, cursor) + start = skip_space(skip_space(end) + 1) # Skip the key's colon. + _value, end = decoder.raw_decode(text, start) + if key == "list": + insertion = len(text[:end - 1].rstrip()) + atomic_write_text(path, text[:insertion] + "," + text[insertion:]) + return + cursor = skip_space(end) + if text[cursor] == ",": + cursor = skip_space(cursor + 1) + try: font = ImageFont.truetype("DejaVuSans.ttf", size=30) except OSError: @@ -100,7 +127,7 @@ def frequently_missing_labels( if match is None: continue try: - data = read_json(json_path) + data = read_json(json_path, repair_trailing_commas=True) entries = data["list"] if not isinstance(entries, list): continue @@ -217,11 +244,11 @@ def _worker_items(base_dir, files_to_process, all_labels, output_queue): nb_pages = json_schema["columns_per_file"][copie_part-1] if json_path.exists(): - # Read strictly for visualization purposes + # Repair accidental trailing commas in user-edited label JSON. bb_list = [] json_name = "" try: - json_result = read_json(json_path) + json_result = read_json(json_path, repair_trailing_commas=True) bb_list = json_result.get("list", []) json_name = json_result.get("name", "") except Exception as e: # noqa: BLE001 - malformed user-editable JSON @@ -439,7 +466,7 @@ class ImageViewer: previous_results = copy.deepcopy(self.accumulated_results) try: - current_data = read_json(self.current_json_path) + current_data = read_json(self.current_json_path, repair_trailing_commas=True) items = current_data["list"] # Perform the conversion now, post-edit @@ -532,6 +559,11 @@ class ImageViewer: def on_edit(self, event): if self.is_viewing and self.current_json_path: + try: + prepare_label_json_for_edit(self.current_json_path) + except (OSError, ValueError) as error: + # Still let the user open and fix other JSON errors manually. + print(f"Could not prepare trailing comma: {error}") print(f"Opening {self.current_json_path}") open_path(self.current_json_path) diff --git a/copienator/json_io.py b/copienator/json_io.py index 598212e..0663360 100644 --- a/copienator/json_io.py +++ b/copienator/json_io.py @@ -19,9 +19,40 @@ class JsonLockTimeout(TimeoutError): pass -def read_json(path: str | Path, *, default: T | object = _MISSING) -> T: +def read_json( + path: str | Path, *, default: T | object = _MISSING, + repair_trailing_commas: bool = False, +) -> T: target = Path(path) try: + if repair_trailing_commas and target.name != "refaire.json": + original = text = target.read_text(encoding="utf-8") + while True: + try: + value = json.loads(text) + except json.JSONDecodeError as error: + # Only remove a comma immediately before a closing + # delimiter reported by the parser, never inside strings. + position = error.pos + # Python 3.14 points at the comma; older versions point + # at the closing bracket or brace. + if error.msg.startswith("Illegal trailing comma"): + position += 1 + while position < len(text) and text[position] in " \t\r\n": + position += 1 + before = text[:position].rstrip() + if (position >= len(text) or text[position] not in "]}" + or not before.endswith(",") + or not before[:-1].rstrip() + or before[:-1].rstrip()[-1] in "[{:,"): + raise + comma = len(before) - 1 + text = text[:comma] + text[comma + 1:] + continue + break + if text != original: + atomic_write_text(target, text) + return cast(T, value) with target.open("r", encoding="utf-8") as stream: if target.name == "refaire.json": # This user-edited selection supports whole-line // comments. diff --git a/copienator/left_margin.py b/copienator/left_margin.py index 3fc4cf6..410fc94 100644 --- a/copienator/left_margin.py +++ b/copienator/left_margin.py @@ -15,7 +15,8 @@ BODY_TOP = .30 BODY_BOTTOM = .82 DISCARD_LEFT_FRACTION = .10 STANDARD_MARGIN_MM = 40 -MIN_MARGIN_MM = STANDARD_MARGIN_MM / 2 +MARGIN_CLEARANCE_MM = 20 +MIN_MARGIN_MM = MARGIN_CLEARANCE_MM def trimmed_left_edge(starts: list[float]) -> float | None: @@ -89,7 +90,7 @@ def format_groups(images: list[Image.Image], reference_width: float) -> list[lis def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: float) -> dict: - """Pool occupied bands per format, trim outliers, and crop at half the edge.""" + """Pool occupied bands and use the larger half-margin or 20 mm-clearance cut.""" starts = [0] * len(images) groups = [] page_estimates = {} @@ -113,7 +114,8 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: left_mm = 0.0 if len(values) >= min(2, len(indices)): status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin' - left_mm = 0.0 if edge < MIN_MARGIN_MM else edge / 2 + left_mm = (0.0 if edge < MIN_MARGIN_MM else + max(edge / 2, edge - MARGIN_CLEARANCE_MM)) # Secondary subpages can start in the middle of an answer. Preserve # their left edge even when the only visible writing is indented. if images[indices[0]].width < .8 * reference_width: @@ -152,8 +154,9 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: 'status': 'unknown' if edge is None else ('low' if edge < MIN_MARGIN_MM else 'normal')}) - return {'version': 6, 'pages': pages, 'starts_px': starts, 'groups': groups, + return {'version': 8, 'pages': pages, 'starts_px': starts, 'groups': groups, 'no_margin_from_page': no_margin_from_page, - 'discard_left_fraction': DISCARD_LEFT_FRACTION, 'cut_fraction_of_margin': .5, + 'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM, + 'cut_fraction_of_margin': .5, 'cut_rule': 'maximum', 'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM, 'body_height_fraction': [BODY_TOP, BODY_BOTTOM]} diff --git a/copienator/workspace.py b/copienator/workspace.py index 1bb3b4d..341e77b 100644 --- a/copienator/workspace.py +++ b/copienator/workspace.py @@ -119,7 +119,10 @@ class EvaluationWorkspace: return self.resolve_file("correction.tex") def is_statement_pdf(self, path: Path) -> bool: - return path in self.input_candidates("enonce.pdf") + return path.parent == self.root and path.name.replace(" ", "") in { + candidate.name.replace(" ", "") + for candidate in self.input_candidates("enonce.pdf") + } @property def metadata_dir(self) -> Path: diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 62e7da6..92c7764 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -180,7 +180,7 @@ def copy_pdf_paths(evaluation: Path) -> list[Path]: ( path for path in location.glob("*.pdf") - if not EvaluationWorkspace(evaluation).is_statement_pdf(path) + if not EvaluationWorkspace(evaluation).is_statement_pdf(evaluation / path.name) ), key=lambda path: path.name.casefold(), ) @@ -1009,7 +1009,9 @@ class CopienatorApp(tk.Tk): self.persp_inventory_label.configure(text=text) def _render_context_controls(self, step: StepDefinition, row: int) -> int: - if step.id == "annotation" and self.variant_var.get() == "grouped": + if step.id == "statement_groups" or ( + step.id == "annotation" and self.variant_var.get() == "grouped" + ): controls = ttk.Frame(self.form) controls.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8)) self.label_groups_button = ttk.Button( @@ -1020,7 +1022,9 @@ class CopienatorApp(tk.Tk): self.label_groups_button.pack(side="left") ttk.Label( controls, - text="Une ligne = un groupe. Vérifiez et enregistrez le fichier avant d’exécuter BGnot.", + text=("Une ligne = un groupe. Vérifiez les groupes actuels avant de lancer Gemini." + if step.id == "statement_groups" else + "Une ligne = un groupe. Vérifiez et enregistrez le fichier avant d’exécuter BGnot."), wraplength=470, justify="left", ).pack(side="left", padx=(10, 0)) @@ -1318,6 +1322,11 @@ class CopienatorApp(tk.Tk): def _open_label_groups(self) -> None: evaluation = self.evaluation + if self.current_step and self.current_step.id == "statement_groups": + target = self.arg_vars.get("target") + value = str(target.get()).strip() if target else "" + if value: + evaluation = (self.repository / value).resolve() self._open_desktop_path( evaluation / "label_groups" if evaluation else None, "fichier label_groups", diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 8addbe1..a354692 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -291,14 +291,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Produit les images de la partie gauche des copies. " "La position est estimée automatiquement sur plusieurs pages de même largeur ; " "Les 10 % de bandes d’écriture les plus à gauche sont ignorées ; " - "la découpe commence à la moitié de la marge restante. " + "la découpe retire le maximum entre la moitié de la marge et la marge moins 2 cm. " "Si la marge est inférieure à 20 mm, elle commence au bord gauche. " "Deux pages consécutives sans marge imposent le bord gauche pour la suite. " "La bande est élargie de 10 %. " "Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. " "Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone " "de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. " - "1 utilise les pages entières, Entrée valide et ! signale une erreur. " + "1 utilise les pages entières, Entrée valide, P/p revient à la copie précédente et ! signale une erreur. " "La validation efface uniquement le signalement de découpe, sans effacer celui des labels." f"{personnal_binding} " "Une copie précise peut être ciblée.", diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index 886ef83..e33a92a 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -99,9 +99,10 @@ pages de largeur comparable. L’estimation cherche le début de l’écriture dans la partie centrale des pages (30–82 % de la hauteur), en ignorant le haut et le bas. Les positions de début d’écriture des bandes sont regroupées pour les pages de même format. Les 10 % de bandes les plus à gauche sont ignorées ; -le début le plus à gauche restant définit la marge. La découpe commence à la -moitié de cette distance. Une marge inférieure à 20 mm (la moitié d’une marge -de référence de 40 mm) commence au bord gauche. Les pages étroites et les groupes sans assez +le début le plus à gauche restant définit la marge. La découpe retire la plus +grande des deux distances : la moitié de la marge ou la marge moins 20 mm. +Une marge inférieure à 20 mm est traitée +comme absente et la découpe commence au bord gauche. Les pages étroites et les groupes sans assez d’écriture restent au bord gauche. La même règle s’applique à toutes les pages, y compris la première. @@ -115,6 +116,7 @@ La fenêtre indique les positions proposées et les cas à vérifier. Vérifiez pour chaque copie que la bande contient bien tous les labels : **←/→** la déplacent, **↑/↓** l’élargissent ou la réduisent ; **Entrée** valide. +**P** ou **p** revient à la copie précédente en conservant les réglages de chaque copie. Ces réglages ajustent la proposition automatique sans relancer l’analyse. Pour reprendre l’ancienne position fixe, cochez **Utiliser la marge fixe** @@ -180,6 +182,10 @@ Vérifiez visuellement les labels. - Quand un label n'est pas dans le bon ordre, il est affiché en rouge. Pour faire des modifications : **e** ouvre le fichier .json à modifier à la main + +Une virgule est ajoutée après le dernier élément de la liste avant l’ouverture, +pour faciliter l’ajout d’un élément. Elle est supprimée automatiquement à la lecture suivante. + - Vous pouvez retirer un label, ou le modifier. - Pour ajouter un label manqué : - cliquer sur l'image là où est le label, ce qui va copie les coordonnées correspondantes diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 3841181..d221b7a 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -208,6 +208,28 @@ class DispatcherTests(unittest.TestCase): class AtomicJsonTests(unittest.TestCase): + def test_label_json_repairs_only_trailing_commas_and_preserves_strings(self) -> None: + text = '{"list": [{"label": "comma,] and ,} and \\\"quoted\\\"",},],}\n' + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "Copie01_01.json" + path.write_text(text) + with self.assertRaises(json.JSONDecodeError): + read_json(path) + value = read_json(path, repair_trailing_commas=True) + self.assertEqual(value, {"list": [{"label": 'comma,] and ,} and "quoted"'}]}) + self.assertEqual(json.loads(path.read_text()), value) + self.assertEqual(path.read_text(), text.replace('",}', '"}').replace('},]', '}]').replace('],}', ']}')) + + def test_trailing_comma_repair_preserves_other_invalid_json(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "Copie01_01.json" + for text in ('[,]', '{,}', '[1,,]', '{"list": [1,], "bad": }', '[1,] garbage'): + with self.subTest(text=text): + path.write_text(text) + with self.assertRaises(json.JSONDecodeError): + read_json(path, repair_trailing_commas=True) + self.assertEqual(path.read_text(), text) + def test_atomic_binary_round_trip(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "result.jsonl" @@ -969,6 +991,27 @@ class StandardCliTests(unittest.TestCase): [original], ) + def test_page_splitter_excludes_statement_aliases_from_copy_inputs(self) -> None: + module = self.modules["page_splitter"] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "DS02VA" + copies = root / "Copies" + copies.mkdir(parents=True) + workspace = EvaluationWorkspace(root) + # Recognize a spaced alias even after it has been moved out of root. + for name in ("enonce.pdf", "énoncé.pdf", "DS02 VA.pdf"): + (copies / name).touch() + copy = copies / "Copie01.pdf" + copy.touch() + self.assertEqual(module._selected_inputs(workspace, copies), [copy]) + statement = root / "DS02 VA.pdf" + statement.touch() + copy = root / "Copie01.pdf" + copy.touch() + self.assertEqual(module._selected_inputs(workspace, root), [copy]) + with self.assertRaises(CliError): + module._selected_inputs(workspace, statement) + def test_plotting_batch_save_is_atomic(self) -> None: module = self.modules["plotting"] with tempfile.TemporaryDirectory() as directory: @@ -1105,6 +1148,40 @@ class StandardCliTests(unittest.TestCase): previous_image, previous_json, previous_meta ) + def test_plotting_edit_prepares_only_the_last_label_comma(self) -> None: + module = self.modules["plotting"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "Copie01_01.json" + text = '{"name": "literal ] and ,}", "list": [{"box_2d": [1,2,3,4], "label": "Ex 1"}], "other": [1,2]}\n' + path.write_text(text) + viewer = module.ImageViewer.__new__(module.ImageViewer) + viewer.is_viewing = True + viewer.current_json_path = path + with patch.object(module, "open_path") as opened: + viewer.on_edit(None) + opened.assert_called_once_with(path) + expected = text.replace('"Ex 1"}]', '"Ex 1"},]') + self.assertEqual(path.read_text(), expected) + module.prepare_label_json_for_edit(path) + self.assertEqual(path.read_text(), expected) + # Pasting a new item works without adding a separator first. + path.write_text(expected.replace('},]', '}, {"label": "Ex 2"},]')) + self.assertEqual([item['label'] for item in read_json(path, repair_trailing_commas=True)['list']], ['Ex 1', 'Ex 2']) + + def test_plotting_edit_preserves_empty_lists_and_other_json_errors(self) -> None: + module = self.modules["plotting"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "Copie01_01.json" + for text in ('{"list": []}', '{"list": [oops]}'): + path.write_text(text) + viewer = module.ImageViewer.__new__(module.ImageViewer) + viewer.is_viewing = True + viewer.current_json_path = path + with patch.object(module, "open_path") as opened: + viewer.on_edit(None) + opened.assert_called_once_with(path) + self.assertEqual(path.read_text(), text) + def test_plotting_accepts_lowercase_and_uppercase_previous_shortcuts(self) -> None: module = self.modules["plotting"] root = Mock() diff --git a/tests/test_left_margin.py b/tests/test_left_margin.py index 30053df..b962675 100644 --- a/tests/test_left_margin.py +++ b/tests/test_left_margin.py @@ -1,6 +1,6 @@ import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch import numpy as np from PIL import Image, ImageDraw, ImageFont @@ -10,6 +10,34 @@ from copienator.commands import cutleft class LeftMarginTests(unittest.TestCase): + def test_review_previous_restores_settings_and_respects_navigation_guards(self): + reviewer = cutleft.ImageReviewer.__new__(cutleft.ImageReviewer) + reviewer.files = [Path('Copie01.pdf'), Path('Copie02.pdf')] + reviewer.index = 0 + reviewer.is_processing = False + reviewer.current_shift = -50 + reviewer.current_width_offset = 100 + reviewer.current_max_per_file = 1 + reviewer.default_max_per_file = 5 + reviewer.review_settings = {} + reviewer.trigger_processing = Mock() + reviewer.on_previous() + reviewer.trigger_processing.assert_not_called() + reviewer._advance() + self.assertEqual(reviewer.index, 1) + self.assertEqual((reviewer.current_shift, reviewer.current_width_offset, reviewer.current_max_per_file), (0, 0, 5)) + reviewer.current_shift = 50 + reviewer.is_processing = True + reviewer.on_previous() + self.assertEqual(reviewer.index, 1) + reviewer.is_processing = False + reviewer.on_previous() + self.assertEqual(reviewer.index, 0) + self.assertEqual((reviewer.current_shift, reviewer.current_width_offset, reviewer.current_max_per_file), (-50, 100, 1)) + reviewer.trigger_processing.assert_called_with(Path('Copie01.pdf'), -50) + reviewer._advance() + self.assertEqual(reviewer.current_shift, 50) + def test_trim_discards_exactly_ten_percent_without_interpolation(self): self.assertEqual(left_margin.trimmed_left_edge([100, 1, 9, 8, 7, 6, 5, 4, 3, 2]), 2) self.assertEqual(left_margin.trimmed_left_edge([5] * 9), 5) @@ -24,14 +52,14 @@ class LeftMarginTests(unittest.TestCase): result = left_margin.analyse_margins(images, 200, 1654) group = result['groups'][0] self.assertEqual(group['edge_mm'], 42) - self.assertEqual(group['left_mm'], 21) + self.assertEqual(group['left_mm'], 22) self.assertEqual(group['discarded_bands'], 14) - self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 4) + self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 4) def test_repeated_left_writing_beyond_ten_percent_is_retained(self): self.assertEqual(left_margin.trimmed_left_edge([10] * 20 + [42] * 80), 10) - def test_disagreeing_pages_use_half_the_trimmed_margin(self): + def test_disagreeing_pages_use_half_margin_when_it_removes_more(self): result = self.analyse([25, 31, 68, 49]) self.assertEqual(result['groups'][0]['left_mm'], 12.5) self.assertEqual(result['starts_px'][1:], [round(12.5 * 200 / 25.4)] * 3) @@ -52,9 +80,9 @@ class LeftMarginTests(unittest.TestCase): return {'edge_mm': edge, 'bands': bands, 'band_starts_mm': [edge] * bands} with patch.object(left_margin, 'estimate_page', side_effect=estimate): result = left_margin.analyse_margins(images, 200, 1654) - self.assertEqual(result['groups'][0]['left_mm'], 21) + self.assertEqual(result['groups'][0]['left_mm'], 22) self.assertEqual(result['no_margin_from_page'], 3) - self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 2 + [0] * 6) + self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 2 + [0] * 6) def test_isolated_low_or_blank_pages_do_not_trigger_no_margin_rule(self): for edges in ([42, 10, 42, 12], [42, 10, None, 12]): @@ -70,11 +98,13 @@ class LeftMarginTests(unittest.TestCase): self.assertGreater(result['starts_px'][0], 0) self.assertEqual(result['starts_px'][2:], [0] * 4) - def test_half_standard_margin_threshold(self): - for edge, expected in ((19.9, 0), (20, 10), (20.1, 10.05), (40, 20)): + def test_larger_cut_distance_and_no_margin_threshold(self): + for edge, expected in ((0, 0), (19.9, 0), (20, 10), (20.1, 10.05), (28.4, 14.2), (40, 20), (60, 40)): with self.subTest(edge=edge): result = self.analyse([edge] * 4) self.assertEqual(result['starts_px'][1:], [round(expected * 200 / 25.4)] * 3) + self.assertEqual(result['groups'][0]['status'], 'near-edge' if edge < 20 else 'margin') + self.assertEqual(result['margin_clearance_mm'], 20) def test_body_bands_ignore_header_footer_and_abstain_on_sparse_ink(self): mask = np.zeros((1169, 827), bool) @@ -119,7 +149,7 @@ class LeftMarginTests(unittest.TestCase): def test_clear_margin_uses_same_conservative_edge_on_every_page(self): result = self.analyse([43, 41, 46, 44]) self.assertEqual(result['groups'][0]['status'], 'margin') - self.assertEqual(result['starts_px'], [round(20.5 * 200 / 25.4)] * 4) + self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 4) self.assertNotIn('first_page_body_start_px', result) def test_sparse_groups_stay_at_left(self): diff --git a/tests/test_statement_personal_flow.py b/tests/test_statement_personal_flow.py index 31ce5ad..b8a004a 100644 --- a/tests/test_statement_personal_flow.py +++ b/tests/test_statement_personal_flow.py @@ -16,6 +16,36 @@ from copienator_gui.workflow import build_workflow class PersonalStatementTests(unittest.TestCase): + def test_sheetinfo_numbering_controls_labels_and_summary(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + blocks = [ + {"id": 42, "numbering": "problem", "indexes": [{"indices": [1, 3]}]}, + {"id": 99, "numbering": "problem_abc", "indexes": [ + {"indices": [1, 3]}, {"indices": [1, 1, 1]}]}, + {"id": 100, "numbering": "exercise", "indexes": [{"indices": [1, 2]}]}, + ] + # Explicit metadata takes precedence over the old Roman heuristic. + (root / "enonce.tex").write_text("\n".join( + "%%SHEETINFO : " + json.dumps(block) + "\n\\Roman" for block in blocks)) + + def fetch(url): + if "/emacs/" in url: + return io.BytesIO(b" 1. Section\n c. Part\n 1. Question\n" + b"###Solution\n c. Answer\n###Rubric\n c. Points") + return io.BytesIO(b"Selected question") + + with patch.object(personal.urllib.request, "urlopen", side_effect=fetch), \ + patch.object(personal, "compile_to_pdf"): + self.assertEqual(personal.process_directory(EvaluationWorkspace(root)), ExitCode.SUCCESS) + expected = ["Ex 1 : I)3)", "Ex 2 : I)C)", "Ex 2 : I)A)1)", "Ex 3 : 1)b)"] + self.assertEqual((root / "labels").read_text().splitlines(), expected) + for label in expected: + for folder in ("Text2", "Sol2"): + self.assertTrue((root / folder / (label + ".tex")).is_file()) + for folder in ("Text", "Sol", "Persp"): + self.assertIn(" C)", (root / folder / "Ex 2").read_text()) + def test_personal_generation_uses_statement_and_groups_by_exercise(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -95,6 +125,16 @@ class SelectiveGeminiTests(unittest.TestCase): before.pop("label_groups") self.assertEqual(after, before) + def test_grouping_follows_label_order_instead_of_gemini_order(self): + self.client.models.generate_content.return_value = self.response({ + "groups": [[self.labels[2]], [self.labels[1], self.labels[0]]], + }) + self.assertEqual(gemini.refine_existing(self.workspace, "groups", api_client=self.client), ExitCode.SUCCESS) + self.assertEqual( + self.workspace.label_groups_file.read_text(), + ", ".join(self.labels[:2]) + "\n" + self.labels[2] + "\n", + ) + def test_invalid_grouping_preserves_existing_groups(self): before = self.snapshot() for groups in ([[self.labels[0]]], [[*self.labels, self.labels[0]]], [[*self.labels, "invented"]]):