Compare commits

...
2 Commits
Author SHA1 Message Date
sebastien c30aac0572 miscs 2026-10-21 22:28:27 +02:00
sebastien 0d8970061e extend filename detection to not take spaces into account 2026-10-21 20:28:07 +02:00
14 changed files with 352 additions and 49 deletions

No files matched your search

+20 -6
View File
@@ -284,6 +284,7 @@ class ImageReviewer:
self.current_width_offset = 0 self.current_width_offset = 0
self.default_max_per_file = default_max_per_file self.default_max_per_file = default_max_per_file
self.current_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.current_preview: Image.Image | None = None
self.is_processing = False self.is_processing = False
self.manual_queue: Queue[ 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 = tk.Label(self.root, text="", font=("Arial", 12, "bold"))
self.label_info.pack(pady=5) self.label_info.pack(pady=5)
self.root.bind("<Return>", self.on_next) self.root.bind("<Return>", 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.bind("!", self.on_skip)
self.root.protocol("WM_DELETE_WINDOW", self.on_close) self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.bind("<Left>", lambda _event: self.on_shift(-CROP_SHIFT_STEP)) self.root.bind("<Left>", lambda _event: self.on_shift(-CROP_SHIFT_STEP))
@@ -343,8 +346,9 @@ class ImageReviewer:
self.on_close() self.on_close()
return return
self.is_processing = False self.is_processing = False
self.current_shift = 0 self.current_shift, self.current_width_offset, self.current_max_per_file = (
self.current_width_offset = 0 self.review_settings.get(self.index, (0, 0, self.default_max_per_file))
)
self.current_result = None self.current_result = None
self.margin_analysis = None self.margin_analysis = None
self.trigger_processing(self.files[self.index], self.current_shift) self.trigger_processing(self.files[self.index], self.current_shift)
@@ -426,7 +430,7 @@ class ImageReviewer:
f"Cols: {schema['columns_per_file']}\n" f"Cols: {schema['columns_per_file']}\n"
f"{margin_info}" f"{margin_info}"
"Arrows: left/right move by 50px | up/down widen/narrow by 50px\n" "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}" f"{personal_instructions}"
), ),
fg="black", fg="black",
@@ -474,11 +478,21 @@ class ImageReviewer:
self.stop_prefetch.set() self.stop_prefetch.set()
self.root.destroy() 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: def _advance(self) -> None:
self._remember_settings()
self.index += 1 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() self.load_current_image()
+49 -8
View File
@@ -32,7 +32,7 @@ def fetch_and_save_sub_text(ex_id, indices, label, text_path):
try: try:
with urllib.request.urlopen(url) as response: with urllib.request.urlopen(url) as response:
content = response.read().decode('utf-8') 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: with open(os.path.join(text_path, f"{label}.tex"), 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
# Compile PDF # Compile PDF
@@ -52,7 +52,7 @@ def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
try: try:
with urllib.request.urlopen(url) as response: with urllib.request.urlopen(url) as response:
content = response.read().decode('utf-8') 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: with open(os.path.join(sol_path, f"{label}.tex"), 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
# Compile PDF # Compile PDF
@@ -70,13 +70,39 @@ def replace_dots(text):
# (?m) enables multiline mode so ^ matches start of each line # (?m) enables multiline mode so ^ matches start of each line
return re.sub(r"(?m)^(\s*.)\.", r"\1)", text) 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.""" """Replaces labels according to spaces depth when problem=True."""
def repl(m): def repl(m):
spaces = m.group(1) spaces = m.group(1)
label = m.group(2) label = m.group(2)
n = len(spaces) n = len(spaces)
try: 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) if n == 1 and label.isdigit(): # 1 space: 1) -> I)
return f"{spaces}{ROMANS_CAP[int(label)]})" return f"{spaces}{ROMANS_CAP[int(label)]})"
elif n == 4 and label.isalpha(): # 4 spaces: a) -> 1) 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 # Matches start of line, spaces, alphanumeric label, and closing parenthesis
return re.sub(r"(?m)^([ \t]+)([a-zA-Z0-9]+)\)", repl, text) 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 indices: return ""
if not problem: if not problem:
res = f"{indices[0]})" res = f"{indices[0]})"
@@ -200,6 +236,11 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
try: try:
data = json.loads(json_str) 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 = [] block_labels = []
# Construct 'ids' parameter # Construct 'ids' parameter
ex_id = str(data['id']) ex_id = str(data['id'])
@@ -222,7 +263,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
fetch_and_save_sub_sol(ids, [], label, paths['Sol2']) fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
else: else:
for item in indexes: 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 "") label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
validate_windows_labels([label]) validate_windows_labels([label])
block_labels.append(label) block_labels.append(label)
@@ -252,9 +293,9 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
# Apply hierarchy depth replace if problem context # Apply hierarchy depth replace if problem context
if problem: if problem:
t_text = replace_problem_labels(t_text) t_text = replace_problem_labels(t_text, numbering or 'problem')
s_text = replace_problem_labels(s_text) s_text = replace_problem_labels(s_text, numbering or 'problem')
p_text = replace_problem_labels(p_text) p_text = replace_problem_labels(p_text, numbering or 'problem')
base_filename = f"Ex {current_ex_num}" base_filename = f"Ex {current_ex_num}"
+3
View File
@@ -381,6 +381,9 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non
) )
groups = LabelGroups.model_validate_json(response.text).groups groups = LabelGroups.model_validate_json(response.text).groups
validate_groups(groups, labels) 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, atomic_write_text(workspace.label_groups_file,
"".join(", ".join(group) + "\n" for group in groups)) "".join(", ".join(group) + "\n" for group in groups))
print(f"Updated label_groups: {len(groups)} Gemini groups.") print(f"Updated label_groups: {len(groups)} Gemini groups.")
+12 -6
View File
@@ -41,9 +41,13 @@ PAGE_SPLITTER_KB = {
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
CM_TO_POINTS = (1 / 2.54) * 72 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()) 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: 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('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('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('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" 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 # The line position is scaled by the same zoom factor
line_x_scaled = self.current_line_x * self.current_zoom line_x_scaled = self.current_line_x * self.current_zoom
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], 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): for index, position in enumerate(self.secondary_splitters):
x = position * self.current_zoom x = position * self.current_zoom
active = index == len(self.secondary_splitters) - 1 active = index == len(self.secondary_splitters) - 1
@@ -664,12 +668,14 @@ def _selected_inputs(
if target.is_file(): if target.is_file():
if target.suffix.casefold() != ".pdf": if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS) 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 backup = workspace.original_copies_dir / target.name
return [backup if backup.is_file() else target] return [backup if backup.is_file() else target]
directory = target directory = target
if target == workspace.copies_dir: if target == workspace.copies_dir:
candidates = list_pdf_files(workspace.copies_dir) candidates = list_pdf_files(workspace.copies_dir, workspace)
candidates = [ candidates = [
( (
workspace.original_copies_dir / path.name workspace.original_copies_dir / path.name
@@ -679,7 +685,7 @@ def _selected_inputs(
for path in candidates for path in candidates
] ]
else: else:
candidates = list_pdf_files(directory) candidates = list_pdf_files(directory, workspace)
return list(reversed(candidates)) return list(reversed(candidates))
+36 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
import copy import copy
import json
import queue import queue
import re import re
import threading import threading
@@ -16,6 +17,7 @@ from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
atomic_write_json, atomic_write_json,
atomic_write_text,
execute, execute,
read_json, read_json,
target_parser, target_parser,
@@ -31,6 +33,31 @@ MISSING_LABEL_COLOR = "orange"
COMMON_MISSING_LABEL_COLOR = "#403a00" COMMON_MISSING_LABEL_COLOR = "#403a00"
COMMON_MISSING_THRESHOLD = 0.66 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: try:
font = ImageFont.truetype("DejaVuSans.ttf", size=30) font = ImageFont.truetype("DejaVuSans.ttf", size=30)
except OSError: except OSError:
@@ -100,7 +127,7 @@ def frequently_missing_labels(
if match is None: if match is None:
continue continue
try: try:
data = read_json(json_path) data = read_json(json_path, repair_trailing_commas=True)
entries = data["list"] entries = data["list"]
if not isinstance(entries, list): if not isinstance(entries, list):
continue 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] nb_pages = json_schema["columns_per_file"][copie_part-1]
if json_path.exists(): if json_path.exists():
# Read strictly for visualization purposes # Repair accidental trailing commas in user-edited label JSON.
bb_list = [] bb_list = []
json_name = "" json_name = ""
try: try:
json_result = read_json(json_path) json_result = read_json(json_path, repair_trailing_commas=True)
bb_list = json_result.get("list", []) bb_list = json_result.get("list", [])
json_name = json_result.get("name", "") json_name = json_result.get("name", "")
except Exception as e: # noqa: BLE001 - malformed user-editable JSON except Exception as e: # noqa: BLE001 - malformed user-editable JSON
@@ -439,7 +466,7 @@ class ImageViewer:
previous_results = copy.deepcopy(self.accumulated_results) previous_results = copy.deepcopy(self.accumulated_results)
try: 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"] items = current_data["list"]
# Perform the conversion now, post-edit # Perform the conversion now, post-edit
@@ -532,6 +559,11 @@ class ImageViewer:
def on_edit(self, event): def on_edit(self, event):
if self.is_viewing and self.current_json_path: 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}") print(f"Opening {self.current_json_path}")
open_path(self.current_json_path) open_path(self.current_json_path)
+32 -1
View File
@@ -19,9 +19,40 @@ class JsonLockTimeout(TimeoutError):
pass 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) target = Path(path)
try: 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: with target.open("r", encoding="utf-8") as stream:
if target.name == "refaire.json": if target.name == "refaire.json":
# This user-edited selection supports whole-line // comments. # This user-edited selection supports whole-line // comments.
+8 -5
View File
@@ -15,7 +15,8 @@ BODY_TOP = .30
BODY_BOTTOM = .82 BODY_BOTTOM = .82
DISCARD_LEFT_FRACTION = .10 DISCARD_LEFT_FRACTION = .10
STANDARD_MARGIN_MM = 40 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: 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: 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) starts = [0] * len(images)
groups = [] groups = []
page_estimates = {} page_estimates = {}
@@ -113,7 +114,8 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
left_mm = 0.0 left_mm = 0.0
if len(values) >= min(2, len(indices)): if len(values) >= min(2, len(indices)):
status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin' 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 # Secondary subpages can start in the middle of an answer. Preserve
# their left edge even when the only visible writing is indented. # their left edge even when the only visible writing is indented.
if images[indices[0]].width < .8 * reference_width: 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 'status': 'unknown' if edge is None else
('low' if edge < MIN_MARGIN_MM else 'normal')}) ('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, '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, 'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM,
'body_height_fraction': [BODY_TOP, BODY_BOTTOM]} 'body_height_fraction': [BODY_TOP, BODY_BOTTOM]}
+13 -2
View File
@@ -87,7 +87,15 @@ class EvaluationWorkspace:
"correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"), "correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"),
} }
names = aliases.get(canonical_name, (canonical_name,)) names = aliases.get(canonical_name, (canonical_name,))
return tuple(self.root / name for name in names) candidates = tuple(self.root / name for name in names)
if canonical_name in aliases and self.root.is_dir():
folder_alias = names[-1].replace(" ", "")
candidates += tuple(
path
for path in sorted(self.root.iterdir())
if path.name.replace(" ", "") == folder_alias and path not in candidates
)
return candidates
def resolve(self, relative_path: str) -> Path: def resolve(self, relative_path: str) -> Path:
"""Resolve a logical workspace path, including statement aliases.""" """Resolve a logical workspace path, including statement aliases."""
@@ -111,7 +119,10 @@ class EvaluationWorkspace:
return self.resolve_file("correction.tex") return self.resolve_file("correction.tex")
def is_statement_pdf(self, path: Path) -> bool: 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 @property
def metadata_dir(self) -> Path: def metadata_dir(self) -> Path:
+12 -3
View File
@@ -180,7 +180,7 @@ def copy_pdf_paths(evaluation: Path) -> list[Path]:
( (
path path
for path in location.glob("*.pdf") 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(), key=lambda path: path.name.casefold(),
) )
@@ -1009,7 +1009,9 @@ class CopienatorApp(tk.Tk):
self.persp_inventory_label.configure(text=text) self.persp_inventory_label.configure(text=text)
def _render_context_controls(self, step: StepDefinition, row: int) -> int: 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 = ttk.Frame(self.form)
controls.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8)) controls.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8))
self.label_groups_button = ttk.Button( self.label_groups_button = ttk.Button(
@@ -1020,7 +1022,9 @@ class CopienatorApp(tk.Tk):
self.label_groups_button.pack(side="left") self.label_groups_button.pack(side="left")
ttk.Label( ttk.Label(
controls, 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, wraplength=470,
justify="left", justify="left",
).pack(side="left", padx=(10, 0)) ).pack(side="left", padx=(10, 0))
@@ -1318,6 +1322,11 @@ class CopienatorApp(tk.Tk):
def _open_label_groups(self) -> None: def _open_label_groups(self) -> None:
evaluation = self.evaluation 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( self._open_desktop_path(
evaluation / "label_groups" if evaluation else None, evaluation / "label_groups" if evaluation else None,
"fichier label_groups", "fichier label_groups",
+2 -2
View File
@@ -291,14 +291,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Produit les images de la partie gauche des copies. " "Produit les images de la partie gauche des copies. "
"La position est estimée automatiquement sur plusieurs pages de même largeur ; " "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 ; " "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. " "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. " "Deux pages consécutives sans marge imposent le bord gauche pour la suite. "
"La bande est élargie de 10 %. " "La bande est élargie de 10 %. "
"Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. " "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 " "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. " "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." "La validation efface uniquement le signalement de découpe, sans effacer celui des labels."
f"{personnal_binding} " f"{personnal_binding} "
"Une copie précise peut être ciblée.", "Une copie précise peut être ciblée.",
+9 -3
View File
@@ -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 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 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 ; 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 le début le plus à gauche restant définit la marge. La découpe retire la plus
moitié de cette distance. Une marge inférieure à 20 mm (la moitié d’une marge grande des deux distances : la moitié de la marge ou la marge moins 20 mm.
de référence de 40 mm) commence au bord gauche. Les pages étroites et les groupes sans assez 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, d’écriture restent au bord gauche. La même règle s’applique à toutes les pages,
y compris la première. 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 : pour chaque copie que la bande contient bien tous les labels :
**←/→** la déplacent, **↑/↓** l’élargissent ou la réduisent ; **Entrée** valide. **←/→** 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. Ces réglages ajustent la proposition automatique sans relancer l’analyse.
Pour reprendre l’ancienne position fixe, cochez **Utiliser la marge fixe** 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. - 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 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. - Vous pouvez retirer un label, ou le modifier.
- Pour ajouter un label manqué : - Pour ajouter un label manqué :
- cliquer sur l'image là où est le label, ce qui va copie les coordonnées correspondantes - cliquer sur l'image là où est le label, ce qui va copie les coordonnées correspondantes
+77
View File
@@ -208,6 +208,28 @@ class DispatcherTests(unittest.TestCase):
class AtomicJsonTests(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: def test_atomic_binary_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "result.jsonl" path = Path(directory) / "result.jsonl"
@@ -969,6 +991,27 @@ class StandardCliTests(unittest.TestCase):
[original], [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: def test_plotting_batch_save_is_atomic(self) -> None:
module = self.modules["plotting"] module = self.modules["plotting"]
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -1105,6 +1148,40 @@ class StandardCliTests(unittest.TestCase):
previous_image, previous_json, previous_meta 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: def test_plotting_accepts_lowercase_and_uppercase_previous_shortcuts(self) -> None:
module = self.modules["plotting"] module = self.modules["plotting"]
root = Mock() root = Mock()
+39 -9
View File
@@ -1,6 +1,6 @@
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import Mock, patch
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@@ -10,6 +10,34 @@ from copienator.commands import cutleft
class LeftMarginTests(unittest.TestCase): 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): 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([100, 1, 9, 8, 7, 6, 5, 4, 3, 2]), 2)
self.assertEqual(left_margin.trimmed_left_edge([5] * 9), 5) 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) result = left_margin.analyse_margins(images, 200, 1654)
group = result['groups'][0] group = result['groups'][0]
self.assertEqual(group['edge_mm'], 42) 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(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): def test_repeated_left_writing_beyond_ten_percent_is_retained(self):
self.assertEqual(left_margin.trimmed_left_edge([10] * 20 + [42] * 80), 10) 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]) result = self.analyse([25, 31, 68, 49])
self.assertEqual(result['groups'][0]['left_mm'], 12.5) self.assertEqual(result['groups'][0]['left_mm'], 12.5)
self.assertEqual(result['starts_px'][1:], [round(12.5 * 200 / 25.4)] * 3) 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} return {'edge_mm': edge, 'bands': bands, 'band_starts_mm': [edge] * bands}
with patch.object(left_margin, 'estimate_page', side_effect=estimate): with patch.object(left_margin, 'estimate_page', side_effect=estimate):
result = left_margin.analyse_margins(images, 200, 1654) 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['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): 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]): 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.assertGreater(result['starts_px'][0], 0)
self.assertEqual(result['starts_px'][2:], [0] * 4) self.assertEqual(result['starts_px'][2:], [0] * 4)
def test_half_standard_margin_threshold(self): def test_larger_cut_distance_and_no_margin_threshold(self):
for edge, expected in ((19.9, 0), (20, 10), (20.1, 10.05), (40, 20)): 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): with self.subTest(edge=edge):
result = self.analyse([edge] * 4) result = self.analyse([edge] * 4)
self.assertEqual(result['starts_px'][1:], [round(expected * 200 / 25.4)] * 3) 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): def test_body_bands_ignore_header_footer_and_abstain_on_sparse_ink(self):
mask = np.zeros((1169, 827), bool) 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): def test_clear_margin_uses_same_conservative_edge_on_every_page(self):
result = self.analyse([43, 41, 46, 44]) result = self.analyse([43, 41, 46, 44])
self.assertEqual(result['groups'][0]['status'], 'margin') 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) self.assertNotIn('first_page_body_start_px', result)
def test_sparse_groups_stay_at_left(self): def test_sparse_groups_stay_at_left(self):
+40
View File
@@ -16,6 +16,36 @@ from copienator_gui.workflow import build_workflow
class PersonalStatementTests(unittest.TestCase): 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): def test_personal_generation_uses_statement_and_groups_by_exercise(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) root = Path(temporary)
@@ -95,6 +125,16 @@ class SelectiveGeminiTests(unittest.TestCase):
before.pop("label_groups") before.pop("label_groups")
self.assertEqual(after, before) 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): def test_invalid_grouping_preserves_existing_groups(self):
before = self.snapshot() before = self.snapshot()
for groups in ([[self.labels[0]]], [[*self.labels, self.labels[0]]], [[*self.labels, "invented"]]): for groups in ([[self.labels[0]]], [[*self.labels, self.labels[0]]], [[*self.labels, "invented"]]):