import argparse import math import shutil import sys from pathlib import Path import pandas as pd from PIL import Image, ImageDraw, ImageFont from copienator.configuration import ( FINAL_SCORE_FONT_PATH, FINAL_SCORE_HISTOGRAM_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR, ) # Configuration constants ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser() OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser() HISTOGRAM_PATH = Path(FINAL_SCORE_HISTOGRAM_PATH).expanduser() def score_font(size): candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"] for candidate in candidates: if not candidate: continue try: return ImageFont.truetype(str(candidate), size) except OSError: continue return ImageFont.load_default() def get_rounded_score(score): """Round score to one decimal place below (floor).""" try: val = float(score) return math.floor(val * 10) / 10 except (ValueError, TypeError): return None def copy_return_artifacts(source_dir: Path, destination_dir: Path) -> None: """Copy the metadata and optional individual answers for one student.""" for filename in ("score.json", "info.json"): source = source_dir / filename if source.is_file(): shutil.copy2(source, destination_dir / filename) else: print(f"Warning: Missing '{source}'.") answers_source = source_dir / "answers" answers_destination = destination_dir / "answers" if answers_destination.is_symlink(): answers_destination.unlink() elif answers_destination.is_dir(): shutil.rmtree(answers_destination) if answers_source.is_dir(): shutil.copytree(answers_source, answers_destination) def copy_histogram(output_dir: Path) -> None: """Copy the score histogram beside the per-student output folders.""" if not HISTOGRAM_PATH.is_file(): print(f"Warning: Missing histogram '{HISTOGRAM_PATH}'.") return destination = output_dir / "histogramme.pdf" shutil.copy2(HISTOGRAM_PATH, destination) print(f"Copied histogram: {destination}") def process_images(base_dir, output_dir): # 1. Load Data try: # header=None assumes the file starts directly with data. # If row 0 is a header, change to header=0 df = pd.read_excel(ODS_PATH, engine="odf", header=None) # Create a lookup dictionary: {Name: Score} score_db = dict(zip(df[0], df[1])) except Exception as e: print(f"CRITICAL ERROR: Could not read ODS file.\n{e}") sys.exit(1) # 2. Prepare Output Directory output_dir.mkdir(parents=True, exist_ok=True) # 3. Iterate Files # Structure: Dir/A Rendre/{name}/{name}.jpg search_path = base_dir / "A Rendre" if not search_path.exists(): print(f"Error: Directory '{search_path}' not found.") sys.exit(1) for student_source in sorted(path for path in search_path.iterdir() if path.is_dir()): image_paths = sorted(student_source.glob("*.jpg")) pdf_paths = sorted(student_source.glob("*.pdf")) media_paths = image_paths or pdf_paths if not media_paths: print(f"Error: No JPG or PDF found in '{student_source}'.") continue student_name = media_paths[0].stem student_output = output_dir / student_name student_output.mkdir(parents=True, exist_ok=True) # Remove files produced by the former flat output layout when migrating # an existing export directory. for suffix in (".jpg", ".pdf"): legacy_output = output_dir / f"{student_name}{suffix}" if legacy_output.is_file() or legacy_output.is_symlink(): legacy_output.unlink() copy_return_artifacts(student_source, student_output) # 4. Find Score if student_name not in score_db: print(f"Error: Student '{student_name}' not found in ODS file.") else: raw_score = score_db[student_name] score = get_rounded_score(raw_score) if score is None: print(f"Error: Invalid score '{raw_score}' for '{student_name}'.") else: # 5. Process Images for img_path in image_paths: try: with Image.open(img_path) as img: img = img.convert("RGB") draw = ImageDraw.Draw(img) width, _height = img.size font_size = int(width * 0.08) font = score_font(font_size) text = str(score) bbox = draw.textbbox((0, 0), text, font=font) text_w = bbox[2] - bbox[0] # 30px padding, top right. x = width - text_w - 30 y = 30 draw.text((x, y), text, fill=(255, 0, 0), font=font) img.save(student_output / img_path.name) print(f"Processed: {student_name} -> {score}") except Exception as e: print(f"Error processing image for '{student_name}': {e}") for pdf_path in pdf_paths: shutil.copy2(pdf_path, student_output / pdf_path.name) copy_histogram(output_dir) def main(argv=None): parser = argparse.ArgumentParser(description="Stamp scores on exam copies.") parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder") args = parser.parse_args(argv) base_dir = args.dir.expanduser().resolve() output_dir = OUTPUT_DIR / base_dir.name output_dir.mkdir(parents=True, exist_ok=True) process_images(base_dir, output_dir) return 0 if __name__ == "__main__": raise SystemExit(main())