miscs
This commit is contained in:
1 parent
0d8970061e
commit
c30aac0572
14 files changed
+343
-48
No files matched your search
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]}
|
||||
@@ -119,7 +119,10 @@ class EvaluationWorkspace:
|
||||
return self.resolve_file("correction.tex")
|
||||
|
||||
def is_statement_pdf(self, path: Path) -> bool:
|
||||
return path in self.input_candidates("enonce.pdf")
|
||||
return path.parent == self.root and path.name.replace(" ", "") in {
|
||||
candidate.name.replace(" ", "")
|
||||
for candidate in self.input_candidates("enonce.pdf")
|
||||
}
|
||||
|
||||
@property
|
||||
def metadata_dir(self) -> Path:
|
||||
|
||||
Reference in new issue
Block a user