Copies/annotating_with_checks.py

210 lines
7.3 KiB
Python

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 <Dir>")
sys.exit(1)
root_dir = sys.argv[1]
results = annotating.make_dictionary(root_dir)
tasks = [(root_dir, sid, lbls) for sid, lbls in results.items()]
# print(tasks)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
results = executor.map(process_student, tasks)
try:
for _ in results:
pass
except Exception:
import traceback
traceback.print_exc()