import sys import os import json import shutil import concurrent.futures import threading import img2pdf from reportlab.pdfgen import canvas # Fix for Matplotlib in threads: Set backend to non-interactive 'Agg' import matplotlib matplotlib.use('Agg') from PIL import Image, ImageDraw, ImageFont import annotating from annotating import MARGIN_LEFT, ANNOT_WIDTH # Global lock for Matplotlib/Latex rendering to prevent race conditions LATEX_LOCK = threading.Lock() DPI = 100 BOX_SIZE = 30 SCORE_BOX_SIZE = 30 SCORES = [x * 0.5 for x in range(9)] # 0.0 to 4.0 try: CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20) except IOError: try: CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20) except IOError: CHECKBOX_FONT = ImageFont.load_default() def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"): if label: draw.text((x - BOX_SIZE-5, y + 2), str(label), fill="black", font=CHECKBOX_FONT) draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2) return [x, y, x + size, y + size] def safe_render_latex(*args, **kwargs): """Thread-safe wrapper for latex rendering.""" with LATEX_LOCK: return annotating.render_latex_text(*args, **kwargs) class CheckboxRenderer: def __init__(self, label_name): self.label = label_name self.checkboxes = [] # List of {type, box, etc.} def callback(self, kind, draw, pos, meta): """ Called by compose_label_image during rendering. pos contains {x, y, w, h} or {box}. meta contains {data, index, etc.} """ if kind == "header_item": # meta['data'] is either result object (for score) or feedback object if meta.get("type") == "score": # Draw score boxes start_x = pos['w'] + 20 for val in SCORES: box = draw_checkbox(draw, start_x, pos['y'] + 5, BOX_SIZE, str(val)) self.checkboxes.append({ "type": "score", "label": self.label, "value": val, "rel_box": box # Will be adjusted for global Y later }) start_x += BOX_SIZE + 60 elif meta.get("type") == "global_fb": # Draw delete box for global feedback bx = pos['w'] - BOX_SIZE - 5 by = pos['y'] + 5 box = draw_checkbox(draw, bx, by, BOX_SIZE) self.checkboxes.append({ "type": "del_global", "label": self.label, "index": meta["index"], "rel_box": box, "text_preview": meta["data"]["text"][:20] }) elif kind == "local_rect": # Delete rect checkbox b = pos['box'] # [xmin, ymin, xmax, ymax] box = draw_checkbox(draw, b[2] - BOX_SIZE, b[1], BOX_SIZE) self.checkboxes.append({ "type": "del_local_rect", "label": self.label, "index": meta["index"], "final_box": box, "text_preview": meta["data"]["text"][:20] }) elif kind == "local_text": # Delete whole local feedback checkbox bx = pos['x'] + pos['w'] - BOX_SIZE by = pos['y'] box = draw_checkbox(draw, bx, by, BOX_SIZE) self.checkboxes.append({ "type": "del_local", "label": self.label, "index": meta["index"], "final_box": box, "text_preview": meta["data"]["text"][:20] }) import re def natural_key(text): return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))] def process_student(args): """Thread worker: Processes one student.""" root_dir, student_id, labels = args print(f"Generating Checkable PDF for: {student_id}") output_dir = os.path.join(root_dir, "Bnot", f"Copie{student_id}") if os.path.exists(output_dir): shutil.rmtree(output_dir) os.makedirs(output_dir) label_images = [] all_checkboxes = [] sorted_labels = sorted(labels.items(), key=lambda x: natural_key(x[0])) for label, content in sorted_labels: pdf_path = content['pdf_path'] if not os.path.exists(pdf_path): continue base_img, _, _ = annotating.make_base_image(pdf_path) # Initialize the hook cb_renderer = CheckboxRenderer(label) # Render using the shared engine final_img = annotating.compose_label_image( base_img, label, content['result'], content['coordinates'][0], render_fn=safe_render_latex, draw_callback=cb_renderer.callback ) label_images.append(final_img) all_checkboxes.append(cb_renderer.checkboxes) if not label_images: return # Concatenate max_w = max(i.width for i in label_images) total_h = sum(i.height for i in label_images) concat_img = Image.new("RGB", (max_w, total_h), "white") final_json_map = [] current_y = 0 for img, boxes in zip(label_images, all_checkboxes): concat_img.paste(img, (0, current_y)) # Adjust coordinates for concatenated image for item in boxes: # item might have 'rel_box' (header) or 'final_box' (local) # Both were relative to the label image. We just add current_y. b = item.get('final_box') or item.get('rel_box') item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y] final_json_map.append(item) current_y += img.height with open(os.path.join(output_dir, "checkboxes.json"), "w") as f: json.dump(final_json_map, f, indent=2) # # Pour éviter du drift, avec img2pdf # # Pb : Xournal can't add annotations # temp_img_path = os.path.join(output_dir, "Reference.jpg") # concat_img.save(temp_img_path, quality=90) # Save as standard image first # with open(os.path.join(output_dir, "Concat.pdf"), "wb") as f: # f.write(img2pdf.convert(temp_img_path)) # Avec reportlab.pdfgen # Au moins, le drift n'empire pas au fil de la copie temp_img_path = os.path.join(output_dir, "Reference.jpg") # Can't use png here concat_img.save(temp_img_path, quality=90) pdf_path = os.path.join(output_dir, "Concat.pdf") w, h = concat_img.size c = canvas.Canvas(pdf_path, pagesize=(w, h)) c.drawImage(temp_img_path, 0, 0, width=w, height=h) c.save() print("Debug : size", w, h) # Ancien code, avec du drift # concat_img.save(os.path.join(output_dir, "Concat.pdf"), "PDF", resolution=DPI) # concat_img.save(os.path.join(output_dir, "Reference.png")) # Try to fix the drift with 72 DPI, non essayé # concat_img.save(os.path.join(output_dir, "Concat.pdf"), "PDF", resolution=72.0) # concat_img.save(os.path.join(output_dir, "Reference.jpg")) if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python annotating_with_checks.py