When no margin detected, reserve a bit more space for comments

This commit is contained in:
sebastien committed 2026-10-13 21:23:48 +02:00
1 parent e7094d850e
commit 78a7b7679c
17 files changed
+357 -28

No files matched your search

+11 -8
View File
@@ -35,9 +35,9 @@ from copienator.commands.import_annotations import clear_import_directory
from copienator.answer_info import build_answer_info
from copienator.filesystem import staged_directory
from copienator.feedback_boxes import valid_feedback_box
from copienator.annotation_layout import layout_for
from copienator.utils import natural_key
MARGIN_LEFT = 300
ANNOT_WIDTH = 600
@@ -321,7 +321,7 @@ def compose_label_image(base_img, label, result, hmin,
with_error=True,
with_empty=False,
more_right=False,
with_id=None, source_pdf=None):
with_id=None, source_pdf=None, layout=None):
"""
Composes the final image with annotations.
@@ -350,10 +350,12 @@ def compose_label_image(base_img, label, result, hmin,
context = f"Copie : {with_id} — {context}"
raise AnnotationLatexError(f"{context}\n{exc}") from exc
layout = dict(layout if layout is not None else layout_for(source_pdf))
margin_left = layout['margin_left']
left_pad = 0
if base_img.width < TARGET_MIN_WIDTH:
total_missing = TARGET_MIN_WIDTH - base_img.width
left_pad = min(total_missing, MARGIN_LEFT)
left_pad = min(total_missing, layout['narrow_left_pad_limit'])
new_base = Image.new("RGB", (TARGET_MIN_WIDTH, base_img.height), "white")
new_base.paste(base_img, (left_pad, 0))
@@ -409,7 +411,7 @@ def compose_label_image(base_img, label, result, hmin,
total_height = base_img.height + header_height
# Create Canvas
final_img = Image.new("RGB", (base_img.width + MARGIN_LEFT, total_height), "white")
final_img = Image.new("RGB", (base_img.width + margin_left, total_height), "white")
# Draw Headers
current_y = 0
@@ -431,7 +433,7 @@ def compose_label_image(base_img, label, result, hmin,
# Paste Base Image
image_offset_y = current_y
final_img.paste(base_img, (MARGIN_LEFT, image_offset_y))
final_img.paste(base_img, (margin_left, image_offset_y))
# 2. Draw Local Annotations
draw = ImageDraw.Draw(final_img, "RGBA") # Refresh draw object
@@ -443,8 +445,8 @@ def compose_label_image(base_img, label, result, hmin,
target_ymin = (ymin - hmin) + image_offset_y
target_ymax = (ymax - hmin) + image_offset_y
target_xmin = xmin + MARGIN_LEFT + left_pad
target_xmax = xmax + MARGIN_LEFT + left_pad
target_xmin = xmin + margin_left + left_pad
target_xmax = xmax + margin_left + left_pad
# Draw Rectangle (if not suppressed)
if "norectangle" not in fb:
@@ -485,6 +487,7 @@ def compose_label_image(base_img, label, result, hmin,
last_text_bottom = paste_y + txt_img.height
final_img.info['annotation_layout'] = layout
return final_img, header_height
def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
@@ -544,7 +547,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
if label_images:
max_w = max(image.width for image in label_images)
total_h = sum(image.height for image in label_images)
canvas = Image.new('RGB', (max_w, total_h))
canvas = Image.new('RGB', (max_w, total_h), 'white')
current_y = 0
for image in label_images:
canvas.paste(image, (0, current_y))
@@ -87,6 +87,7 @@ def save_batch(batch, prefix, group_id, output_root: Path) -> None:
"id": student_id,
"label": label,
"header_height": header_height,
"layout": image.info.get('annotation_layout', annotating.layout_for(None)),
"hmin": current_y,
"hmax": current_y + image.height,
}
@@ -196,6 +196,7 @@ def _render_student(
"id": student_id,
"label": label,
"header_height": header_height,
"layout": final_image.info.get('annotation_layout', annotating.layout_for(None)),
"img_h": final_image.height,
}
)
+8 -8
View File
@@ -7,7 +7,6 @@ import json
import os
import signal
import shlex
import shutil
import sys
import threading
import time
@@ -20,6 +19,7 @@ from google.genai import types
from copienator import configuration as config
from copienator.commands import grouping
from copienator.answer_origins import save_origins
from copienator.annotation_layout import copy_answer_pdf, move_answer_pdf
from copienator import prompting
from copienator.gemini_config import fresh_seed, thinking_config
from copienator import configuration as settings
@@ -427,10 +427,10 @@ def handle_label_errors(pid, label, res, pdf_path):
tprint(f"\t\tCopie{pid} : moving wrong {label} to {new_label}.")
# Copie vers _new, puis renommage de l'original vers _old
shutil.copy(str(pdf_path), str(new_pdf_path))
copy_answer_pdf(pdf_path, new_pdf_path)
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
move_answer_pdf(pdf_path, old_pdf_path)
idx = reserve_next_group_idx(new_label)
height = grouping.get_pdf_height(str(new_pdf_path))
@@ -464,7 +464,7 @@ def handle_label_errors(pid, label, res, pdf_path):
add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}_new.pdf"
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
shutil.copy(str(pdf_path), str(add_pdf_path))
copy_answer_pdf(pdf_path, add_pdf_path)
save_origins(INPUT_DIR, pid, add_label, add_pdf_path, [label])
tprint(f"\t\tCopying Copie{pid} : {label} -> {add_label}")
idx = reserve_next_group_idx(add_label)
@@ -578,7 +578,7 @@ def process_single_task(task_tuple, precomputed_response=None):
if res.get("error") == "empty-answer":
old_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path.exists() and pdf_path != old_path:
shutil.move(str(pdf_path), str(old_path))
move_answer_pdf(pdf_path, old_path)
pdf_path = old_path
current_suffix = "_old"
@@ -729,10 +729,10 @@ def resolve_delayed_moves():
res["suffix"] = "_old" # Fixed typo: was suffixe
resolved_any = True
shutil.copy(str(pdf_path), str(new_pdf_path))
copy_answer_pdf(pdf_path, new_pdf_path)
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
move_answer_pdf(pdf_path, old_pdf_path)
idx = reserve_next_group_idx(target_label)
height = grouping.get_pdf_height(str(new_pdf_path))
@@ -751,7 +751,7 @@ def resolve_delayed_moves():
res["error"] = res["error"].replace(f"(xx){target_label}", f"(->){target_label}")
resolved_any = True
shutil.copy(str(pdf_path), str(add_pdf_path))
copy_answer_pdf(pdf_path, add_pdf_path)
save_origins(INPUT_DIR, pid, target_label, add_pdf_path, [label])
idx = reserve_next_group_idx(target_label)
height = grouping.get_pdf_height(str(add_pdf_path))
+3
View File
@@ -29,6 +29,7 @@ from copienator.filesystem import staged_files
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.configuration import SHOW_PERSONAL_STEPS
from copienator.left_margin import analyse_margins
from copienator.annotation_layout import fingerprint
DELIMITER_WIDTH = 5
DELIMITER_COLOR = (0, 0, 0)
@@ -211,6 +212,8 @@ def save_results(
) -> None:
"""Atomically replace every Cutleft output associated with one copy."""
_, splits, schema = result
if pdf_path.is_file():
schema = {**schema, 'source_sha256': fingerprint(pdf_path)}
base_name = pdf_path.stem
previous = _previous_cutleft_outputs(output_dir, base_name)
with staged_files(output_dir, remove=previous) as staging:
@@ -22,6 +22,7 @@ from copienator import (
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.annotation_layout import legacy_layout
from copienator.annotation_modifications import (
BASELINE,
output_removals,
@@ -224,6 +225,8 @@ def apply_actions_and_regenerate(
content["coordinates"][0],
with_error=False,
with_empty=True,
source_pdf=pdf_path,
layout=image_info.get('layout', legacy_layout()),
)
if final_image is None:
incomplete = True
@@ -25,6 +25,7 @@ from copienator import (
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_layout import legacy_layout
from copienator.annotation_modifications import (
BASELINE,
baseline,
@@ -168,7 +169,6 @@ def _scan_annotation_directory(
if notes_image is None:
if required:
raise ValueError(f"Could not read annotations in {directory}")
return {}, {}
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
for action in actions:
@@ -181,12 +181,13 @@ def _scan_annotation_directory(
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
if student_id and label and hmax > hmin:
crop = notes_image.crop((0, hmin, notes_image.width, hmax))
if has_significant_notes(crop):
notes_by_student[student_id][label] = {
"img": crop,
"old_header_h": int(image_info.get("header_height", 0)),
}
crop = (notes_image.crop((0, hmin, notes_image.width, hmax))
if notes_image is not None else None)
notes_by_student[student_id][label] = {
"img": crop if has_significant_notes(crop) else None,
"old_header_h": int(image_info.get("header_height", 0)),
"layout": image_info.get('layout', legacy_layout()),
}
return dict(actions_by_student), dict(notes_by_student)
@@ -346,6 +347,8 @@ def apply_actions_and_regenerate_grouped(
content["coordinates"][0],
with_error=False,
with_empty=True,
source_pdf=pdf_path,
layout=label_notes.get(label, {}).get('layout', legacy_layout()),
)
if final_image is None:
incomplete = True
+12 -4
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import argparse
import re
import shutil
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
@@ -10,6 +9,8 @@ from typing import Any
from pypdf import PdfWriter
from copienator.pdf_cut import split_pdf
from copienator.annotation_layout import (copy_answer_pdf, move_answer_pdf,
page_parts, save_parts, metadata_path)
from copienator.answer_origins import origins_for, save_origins
from copienator import (
@@ -240,6 +241,8 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
if not instruction.cut and instruction.operator == "->":
origins[key_new] = list(dict.fromkeys([
*origins[key_new], instruction.old_label]))
source_parts = page_parts(source)
destination_parts = page_parts(destination)
writer = PdfWriter()
try:
if instruction.pipe_first:
@@ -251,8 +254,11 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
writer.append(destination)
writer.append(source)
writer.write(temp_output)
save_parts(temp_output, (source_parts + destination_parts
if instruction.pipe_first else destination_parts + source_parts))
except Exception:
temp_output.unlink(missing_ok=True)
metadata_path(temp_output).unlink(missing_ok=True)
raise
finally:
writer.close()
@@ -264,12 +270,13 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
[instruction.old_label] if instruction.operator == "-x"
and not instruction.cut else []
)
shutil.copy(source, temp_output)
copy_answer_pdf(source, temp_output)
current_paths[key_new] = temp_output
temp_files.append(temp_output)
except Exception:
for temporary in temp_files:
temporary.unlink(missing_ok=True)
metadata_path(temporary).unlink(missing_ok=True)
raise
for pdf in files_to_old:
@@ -280,7 +287,7 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
old_name = pdf.with_name(f"{label}_old.pdf")
if pdf != old_name:
old_name.unlink(missing_ok=True)
shutil.move(str(pdf), str(old_name))
move_answer_pdf(pdf, old_name)
set_suffix_and_clean_error(results, copy_id, label, "_old")
for instruction in instructions:
@@ -298,7 +305,7 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
continue
final_name = workspace.copies_dir / f"Copie{copy_id}" / f"{label}_new.pdf"
final_name.unlink(missing_ok=True)
shutil.move(str(current_path), str(final_name))
move_answer_pdf(current_path, final_name)
retained_origins = [origin for origin in origins[(copy_id, label)]
if (copy_id, origin) not in initial_paths
or initial_paths[(copy_id, origin)] not in files_to_old]
@@ -312,6 +319,7 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
for temporary in temp_files:
if temporary not in used_temps:
temporary.unlink(missing_ok=True)
metadata_path(temporary).unlink(missing_ok=True)
for copy_id, label, target in dismissed_conflicts:
# A removed instruction acknowledges the conflict without touching PDFs.
+17
View File
@@ -22,6 +22,7 @@ from copienator import (
workspace_from_target,
)
from copienator.filesystem import staged_directory
from copienator.annotation_layout import fingerprint, metadata_path, save_parts
SQUARE = 1000 // 38
ANSWER_TOP_PADDING_POINTS = 4 * 72 / 25.4
@@ -129,6 +130,11 @@ def _render_split_outputs(
visible_bounds = _prepare_split_pages(document)
parsed = _parse_coordinates(coords_list)
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)
provenance: defaultdict[str, list[dict]] = defaultdict(list)
schema = read_json(input_pdf.parent.parent / 'Cutleft' / f'{input_pdf.stem}_schema.json', default={})
analysis = schema.get('auto_margin') or {}
measurements = ({item['page']: item for item in analysis.get('pages', [])}
if schema.get('source_sha256') == fingerprint(input_pdf) else {})
with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory:
temporary = Path(temp_directory)
for index, item in enumerate(parsed):
@@ -200,6 +206,15 @@ def _render_split_outputs(
part_path,
)
parts_by_label[clean_label].append(part_path)
edge = measurements.get(page_number + 1, {}).get('edge_mm')
remaining = None if edge is None else max(0, edge - clip.x0 * 25.4 / 72)
provenance[clean_label].append({
'source': input_pdf.name, 'source_sha256': schema.get('source_sha256'),
'page': page_number + 1, 'crop_points': list(clip),
'margin_mm': remaining,
'margin_status': ('unknown' if remaining is None else
'low' if remaining < 20 else 'normal'),
})
generated: set[str] = set()
for label, parts in parts_by_label.items():
@@ -212,6 +227,8 @@ def _render_split_outputs(
finally:
merger.close()
generated.add(filename)
save_parts(staging / filename, provenance[label])
generated.add(metadata_path(staging / filename).name)
return generated
finally:
document.close()