Compare commits
2
Commits
9335b6d5da
...
c30aac0572
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c30aac0572 | ||
|
|
0d8970061e |
No files matched your search
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"]]):
|
||||||
|
|||||||
Reference in new issue
Block a user