#!/usr/bin/env python3 import argparse import base64 import json import math import re import shutil import subprocess import sys import tempfile import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image OPTION_LETTERS = ["A", "B", "C", "D"] def run_command(args): completed = subprocess.run(args, check=False, capture_output=True) if completed.returncode != 0: stdout = completed.stdout.decode("utf-8", errors="ignore").strip() stderr = completed.stderr.decode("utf-8", errors="ignore").strip() detail = stderr or stdout or "no command output" raise RuntimeError(f"{args[0]} failed with exit {completed.returncode}: {detail[:1400]}") return completed def run_command_optional(args): return subprocess.run(args, check=False, capture_output=True) def normalize_pdf_for_poppler(pdf_path): if not shutil.which("qpdf"): return pdf_path normalized_path = pdf_path.with_name(f"{pdf_path.stem}-normalized.pdf") completed = run_command_optional([ "qpdf", "--object-streams=disable", str(pdf_path), str(normalized_path), ]) if completed.returncode in (0, 3) and normalized_path.exists() and normalized_path.stat().st_size > 0: return normalized_path return pdf_path def get_pdf_page_count(pdf_path): info = run_command(["pdfinfo", str(pdf_path)]).stdout.decode("utf-8", errors="ignore") match = re.search(r"^Pages:\s+(\d+)\s*$", info, re.MULTILINE) if not match: raise RuntimeError("Could not read page count from PDF.") return int(match.group(1)) def extract_questions(pdf_path, settings, output_dir=None): pdf_path = normalize_pdf_for_poppler(pdf_path) page_count = get_pdf_page_count(pdf_path) start_page = int(settings.get("startPage", 1)) end_page = int(settings.get("endPage", start_page)) if start_page < 1: start_page = 1 if end_page > page_count: end_page = page_count if start_page > page_count or start_page > end_page: raise RuntimeError(f"Invalid page range {start_page}-{end_page}. PDF has {page_count} pages.") first_question = int(settings.get("firstQuestion", 1)) expected_questions = int(settings.get("expectedQuestions", 75)) answer_mode = settings.get("answerMode", "mcq") marker_limit = first_question + expected_questions - 1 dpi = 216 markers = [] page_sizes = {} for page_number in range(start_page, end_page + 1): bbox_xml = run_command([ "pdftotext", "-bbox", "-f", str(page_number), "-l", str(page_number), str(pdf_path), "-" ]).stdout.decode("utf-8", errors="ignore") page_markers, page_size = find_question_markers(bbox_xml, page_number, first_question, marker_limit) page_sizes[page_number] = page_size markers.extend(page_markers) markers = normalize_markers(markers, first_question, marker_limit) if not markers: raise RuntimeError("No question numbers were detected. Try changing the start page or end page.") with tempfile.TemporaryDirectory() as temp_dir: prefix = Path(temp_dir) / "page" run_command([ "pdftoppm", "-f", str(start_page), "-l", str(end_page), "-r", str(dpi), "-png", str(pdf_path), str(prefix) ]) page_images = {} for page_number in sorted({marker["pageNumber"] for marker in markers}): page_images[page_number] = Image.open(find_rendered_page(prefix, page_number)).convert("RGB") questions = [] for index, marker in enumerate(markers[:expected_questions]): previous_marker = markers[index - 1] if index > 0 else None next_marker = markers[index + 1] if index + 1 < len(markers) else None output_path = Path(output_dir) / f"q-{marker['questionNo']}.png" if output_dir else None image = crop_question(page_images[marker["pageNumber"]], marker, previous_marker, next_marker, dpi, output_path) question = { "id": f"q-{marker['questionNo']}-{marker['pageNumber']}", "questionNo": marker["questionNo"], "pageNumber": marker["pageNumber"], "subject": subject_for_question(marker["questionNo"], expected_questions), "answerType": answer_type_for_question(marker["questionNo"], answer_mode), "width": image["width"], "height": image["height"] } if "dataUrl" in image: question["imageUrl"] = image["dataUrl"] if "imagePath" in image: question["imagePath"] = image["imagePath"] questions.append(question) return {"questions": questions} def find_question_markers(bbox_xml, page_number, first_question, marker_limit): root = ET.fromstring(bbox_xml) page = root.find(".//{*}page") if page is None: return [], {"width": 612.0, "height": 792.0} page_width = float(page.attrib.get("width", "612")) page_height = float(page.attrib.get("height", "792")) markers = [] for word in page.findall(".//{*}word"): text = re.sub(r"\s+", "", word.text or "").strip() match = re.match(r"^(\d{1,3})(\.)?$", text) if not match: continue question_no = int(match.group(1)) if question_no < first_question or question_no > marker_limit: continue x_min = float(word.attrib["xMin"]) y_min = float(word.attrib["yMin"]) has_dot = bool(match.group(2)) question_column_left = page_width * 0.055 question_column_right = page_width * (0.12 if has_dot else 0.095) inside_question_column = question_column_left <= x_min <= question_column_right inside_content_band = page_height * 0.05 <= y_min <= page_height * 0.93 if not inside_question_column or not inside_content_band: continue markers.append({ "questionNo": question_no, "pageNumber": page_number, "x": x_min, "y": y_min, "pageWidth": page_width, "pageHeight": page_height }) deduped = [] for marker in sorted(markers, key=lambda item: item["y"]): previous = deduped[-1] if deduped else None if previous and previous["questionNo"] == marker["questionNo"] and abs(previous["y"] - marker["y"]) <= 7: continue deduped.append(marker) return deduped, {"width": page_width, "height": page_height} def normalize_markers(raw_markers, first_question, marker_limit): accepted = [] last_question = first_question - 1 for marker in sorted(raw_markers, key=lambda item: (item["pageNumber"], item["y"])): if marker["questionNo"] <= last_question or marker["questionNo"] > marker_limit: continue accepted.append(marker) last_question = marker["questionNo"] return accepted def find_rendered_page(prefix, page_number): candidates = [ Path(f"{prefix}-{page_number}.png"), Path(f"{prefix}-{page_number:02d}.png"), Path(f"{prefix}-{page_number:03d}.png"), ] for candidate in candidates: if candidate.exists(): return candidate raise FileNotFoundError(f"Rendered page image not found for page {page_number}") def crop_question(page_image, marker, previous_marker, next_marker, dpi, output_path=None): pixels_per_point = dpi / 72 width, height = page_image.size top_content = int(4 * pixels_per_point) bottom_content = int(height - 18 * pixels_per_point) top_padding = int(25 * pixels_per_point) bottom_gap = int(6 * pixels_per_point) marker_y = int(marker["y"] * pixels_per_point) next_marker_y = int(next_marker["y"] * pixels_per_point) if next_marker and next_marker["pageNumber"] == marker["pageNumber"] else None # Question markers are restricted to the real left-column "1." style labels, # so the crop can safely end just before the next marker. adjusted_top = clamp(marker_y - top_padding, top_content, height - 20) raw_bottom = next_marker_y - bottom_gap if next_marker_y is not None else bottom_content adjusted_bottom = clamp(raw_bottom, adjusted_top + int(110 * pixels_per_point), bottom_content) crop = page_image.crop((0, adjusted_top, width, adjusted_bottom)) if output_path: output_path.parent.mkdir(parents=True, exist_ok=True) crop.save(output_path, format="PNG", compress_level=1) return { "imagePath": str(output_path), "width": crop.width, "height": crop.height } with tempfile.NamedTemporaryFile(suffix=".png") as temp_file: crop.save(temp_file.name, format="PNG", compress_level=1) encoded = base64.b64encode(Path(temp_file.name).read_bytes()).decode("ascii") return { "dataUrl": f"data:image/png;base64,{encoded}", "width": crop.width, "height": crop.height } def find_top_boundary_before_question(image, previous_marker_y, current_marker_y, pixels_per_point): width, height = image.size left = int(10 * pixels_per_point) right = width - left start_y = clamp(previous_marker_y + int(55 * pixels_per_point), 0, height - 1) end_y = clamp(current_marker_y - int(3 * pixels_per_point), start_y + 1, height) if end_y <= start_y + int(16 * pixels_per_point): return current_marker_y - int(18 * pixels_per_point) minimum_ink_pixels = max(8, int((right - left) * 0.002)) pixels = image.load() blank_run = 0 required_blank = max(8, int(3 * pixels_per_point)) seen_current_ink = False for y in range(end_y - 1, start_y - 1, -1): ink_pixels = 0 for x in range(left, right): red, green, blue = pixels[x, y] if red < 170 or green < 170 or blue < 170: ink_pixels += 1 if ink_pixels >= minimum_ink_pixels: break if ink_pixels >= minimum_ink_pixels: seen_current_ink = True blank_run = 0 elif seen_current_ink: blank_run += 1 if blank_run >= required_blank: return clamp( y + blank_run // 2, previous_marker_y + int(55 * pixels_per_point), current_marker_y - int(6 * pixels_per_point) ) return clamp( current_marker_y - int(18 * pixels_per_point), previous_marker_y + int(55 * pixels_per_point), current_marker_y - int(6 * pixels_per_point) ) def find_bottom_boundary_after_question(image, current_marker_y, next_marker_y, pixels_per_point): width, height = image.size left = int(60 * pixels_per_point) right = width - left start_y = clamp(current_marker_y + int(55 * pixels_per_point), 0, height - 1) end_y = clamp(next_marker_y - int(3 * pixels_per_point), start_y + 1, height) if end_y <= start_y + int(16 * pixels_per_point): return next_marker_y - int(10 * pixels_per_point) minimum_ink_pixels = max(8, int((right - left) * 0.002)) row_has_ink = [] pixels = image.load() for y in range(start_y, end_y): ink_pixels = 0 for x in range(left, right): red, green, blue = pixels[x, y] if red < 90 and green < 90 and blue < 90: ink_pixels += 1 if ink_pixels >= minimum_ink_pixels: break row_has_ink.append(ink_pixels >= minimum_ink_pixels) blank_run = 0 required_blank = max(8, int(3 * pixels_per_point)) seen_ink = False for row, has_ink in enumerate(row_has_ink): if has_ink: seen_ink = True blank_run = 0 elif seen_ink: blank_run += 1 if blank_run >= required_blank: return clamp( start_y + row - blank_run // 2, current_marker_y + int(55 * pixels_per_point), next_marker_y - int(6 * pixels_per_point) ) return clamp( next_marker_y - int(10 * pixels_per_point), current_marker_y + int(55 * pixels_per_point), next_marker_y - int(6 * pixels_per_point) ) def extract_key(pdf_path, questions): pdf_path = normalize_pdf_for_poppler(pdf_path) page_count = get_pdf_page_count(pdf_path) text = run_command([ "pdftotext", "-layout", "-f", "1", "-l", str(min(2, page_count)), str(pdf_path), "-" ]).stdout.decode("utf-8", errors="ignore") key_section = isolate_key_section(text) tokens = re.findall(r"-?\d+(?:\.\d+)?", key_section) question_by_no = {int(question["questionNo"]): question for question in questions} answers = {} for index in range(0, len(tokens) - 1, 2): question_no = int(float(tokens[index])) answer = tokens[index + 1] question = question_by_no.get(question_no) if not question: continue answers[str(question_no)] = normalize_key_value(answer, question.get("answerType", "mcq")) return { "correctAnswers": answers, "answerKeyText": "\n".join(f"{key} {answers[key]}" for key in sorted(answers, key=lambda item: int(item))) } def isolate_key_section(text): lower = text.lower() start_index = lower.find("key sheet") start = start_index + len("key sheet") if start_index >= 0 else 0 end_candidates = [] for needle in [" solution", "\n sec:", "\nsec:", " page 1"]: index = lower.find(needle, start) if index >= 0: end_candidates.append(index) end = min(end_candidates) if end_candidates else len(text) return text[start:end] def normalize_key_value(value, answer_type): stripped = str(value).strip() if answer_type == "mcq": if re.match(r"^[1-4]$", stripped): return OPTION_LETTERS[int(stripped) - 1] return stripped.upper() return stripped def subject_for_question(question_no, expected_questions): if expected_questions >= 75: if question_no <= 25: return "Mathematics" if question_no <= 50: return "Physics" return "Chemistry" return "Paper" def answer_type_for_question(question_no, answer_mode): if answer_mode == "mcq": return "mcq" position = ((question_no - 1) % 25) + 1 return "numeric" if position > 20 else "mcq" def clamp(value, minimum, maximum): return min(max(int(value), int(minimum)), int(maximum)) def main(): parser = argparse.ArgumentParser() parser.add_argument("mode", choices=["questions", "key"]) parser.add_argument("--pdf", required=True) parser.add_argument("--settings-json", default="{}") parser.add_argument("--questions-json", default="[]") parser.add_argument("--output-dir") args = parser.parse_args() try: if args.mode == "questions": print(json.dumps(extract_questions(Path(args.pdf), json.loads(args.settings_json), args.output_dir))) else: print(json.dumps(extract_key(Path(args.pdf), json.loads(args.questions_json)))) except Exception as error: print(json.dumps({"error": str(error)})) sys.exit(1) if __name__ == "__main__": main()