This commit is contained in:
sebastien committed 2026-10-21 22:28:27 +02:00
1 parent 0d8970061e
commit c30aac0572
14 files changed
+343 -48

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)