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.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("<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.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.bind("<Left>", 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()
+49 -8
View File
@@ -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}"
+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
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.")
+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)
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))
+36 -4
View File
@@ -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)
+32 -1
View File
@@ -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.
+8 -5
View File
@@ -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]}
+13 -2
View File
@@ -87,7 +87,15 @@ class EvaluationWorkspace:
"correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"),
}
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:
"""Resolve a logical workspace path, including statement aliases."""
@@ -111,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:
+12 -3
View File
@@ -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",
+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. "
"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.",
+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
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
+77
View File
@@ -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()
+39 -9
View File
@@ -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):
+40
View File
@@ -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"]]):