Spaces:
Paused
Paused
| """Shared helpers for native-table parsers. | |
| Answer-sheet PDFs (e.g. the `考生作答一覽表` format) contain a grid where each | |
| logical student row is split by the extractor into: | |
| - a "header" row carrying class / seat / name (and per-row score cells) | |
| - one or more follow-up "data" rows carrying the letter strings | |
| Example extracted rows: | |
| ['902', '13', '陳筱琳', '', '', '', '', ..., '51', None, None, None, '51', ...] | |
| [None, None, None, '==CBBBA==C', '===B======', 'B=B==BB==B', ...] | |
| We walk the rows statefully, grouping follow-up data rows into the most recent | |
| header, then extract letters from the combined cell set. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| from ..answer_grid import normalize_letter | |
| # Markers that identify the answer-key header row. | |
| ANSWER_KEY_MARKERS = ("預設標準答案", "標準答案", "answer key") | |
| # Chars kept when extracting letters from a cell. | |
| _ANSWER_CELL_ALLOWED = re.compile(r"[A-Za-z=\-–—]") | |
| # A cell counts as a "letter cell" if most of its chars survive filtering and | |
| # it has at least 3 chars. This prevents single-char artifacts like a class | |
| # number from being mis-read as one letter. | |
| _LETTER_CELL_MIN_LEN = 3 | |
| class _Group: | |
| """A header row plus any trailing data rows that belong to it.""" | |
| header: list | |
| data_rows: list[list] = field(default_factory=list) | |
| def _cell_text(cell) -> str: | |
| if cell is None: | |
| return "" | |
| return str(cell).strip() | |
| def _is_classlike(value: str) -> bool: | |
| return bool(re.fullmatch(r"\d{2,4}", value)) | |
| def _is_empty_cell(cell) -> bool: | |
| return _cell_text(cell) == "" | |
| def _is_answer_key_header(row: list) -> bool: | |
| text = " ".join(_cell_text(c) for c in row) | |
| return any(marker in text for marker in ANSWER_KEY_MARKERS) | |
| def _is_student_header(row: list) -> bool: | |
| """Header row has a class-like number + a non-empty name cell.""" | |
| first_vals = [c for c in row if not _is_empty_cell(c)] | |
| if len(first_vals) < 2: | |
| return False | |
| # Look for class pattern in the leading cells | |
| head = [_cell_text(c) for c in row[:3]] | |
| head_nonempty = [h for h in head if h] | |
| if not head_nonempty: | |
| return False | |
| if not _is_classlike(head_nonempty[0]): | |
| return False | |
| # At least one later value should look like a name (non-letter-cell) | |
| return True | |
| def _is_data_row(row: list) -> bool: | |
| """A data row has empty name/class columns and letter cells elsewhere.""" | |
| head = row[:3] | |
| if any(not _is_empty_cell(c) for c in head): | |
| return False | |
| return any(_looks_like_letter_cell(c) for c in row) | |
| def _looks_like_letter_cell(cell) -> bool: | |
| text = _cell_text(cell) | |
| if len(text) < _LETTER_CELL_MIN_LEN: | |
| return False | |
| kept = "".join(_ANSWER_CELL_ALLOWED.findall(text)) | |
| return len(kept) >= _LETTER_CELL_MIN_LEN and len(kept) >= len(text) * 0.7 | |
| def _extract_letters_from_cell(cell) -> list[Optional[str]]: | |
| clean = "".join(_ANSWER_CELL_ALLOWED.findall(_cell_text(cell))) | |
| return [normalize_letter(ch) for ch in clean] | |
| def _collect_letters(group: _Group) -> list[Optional[str]]: | |
| """Concatenate letter cells across header + data rows, in order.""" | |
| letters: list[Optional[str]] = [] | |
| for row in (group.header, *group.data_rows): | |
| for cell in row: | |
| if _looks_like_letter_cell(cell): | |
| letters.extend(_extract_letters_from_cell(cell)) | |
| return letters | |
| def _extract_name(row: list) -> Optional[str]: | |
| """Return the first non-empty, non-numeric cell in positions 0-4.""" | |
| for cell in row[:5]: | |
| text = _cell_text(cell) | |
| if not text: | |
| continue | |
| if re.fullmatch(r"-?\d+(\.\d+)?", text): | |
| continue | |
| if _looks_like_letter_cell(text): | |
| continue | |
| return text | |
| return None | |
| def _extract_student_id(row: list) -> Optional[str]: | |
| """Return 'class-seat' from the header row when present.""" | |
| head = [_cell_text(c) for c in row[:3]] | |
| class_val = next((h for h in head if _is_classlike(h)), None) | |
| if not class_val: | |
| return None | |
| seat_val = next( | |
| (h for h in head if h and h != class_val and re.fullmatch(r"\d{1,3}", h)), | |
| None, | |
| ) | |
| return f"{class_val}-{seat_val}" if seat_val else class_val | |
| def _group_rows(rows: list[list]) -> tuple[Optional[_Group], list[_Group]]: | |
| """Walk rows and group each header with its trailing data rows. | |
| Returns (answer_key_group, student_groups). | |
| """ | |
| answer_key: Optional[_Group] = None | |
| students: list[_Group] = [] | |
| current: Optional[_Group] = None | |
| bucket: Optional[str] = None # "key" or "student" | |
| for row in rows: | |
| if _is_answer_key_header(row): | |
| current = _Group(header=row) | |
| answer_key = current | |
| bucket = "key" | |
| continue | |
| if _is_student_header(row): | |
| current = _Group(header=row) | |
| students.append(current) | |
| bucket = "student" | |
| continue | |
| if current is not None and _is_data_row(row): | |
| current.data_rows.append(row) | |
| continue | |
| # Non-data, non-header row ends the current group. | |
| if bucket is not None and not _is_data_row(row): | |
| # Only reset on rows with visible header-like content (avoid noise rows) | |
| if any(not _is_empty_cell(c) for c in row[:3]): | |
| current = None | |
| bucket = None | |
| return answer_key, students | |
| def table_to_student_answers(rows: list[list]) -> dict: | |
| _, student_groups = _group_rows(rows) | |
| students: list[dict] = [] | |
| for g in student_groups: | |
| name = _extract_name(g.header) | |
| if not name: | |
| continue | |
| letters = _collect_letters(g) | |
| if not letters: | |
| continue | |
| students.append( | |
| { | |
| "name": name, | |
| "id": _extract_student_id(g.header) or "", | |
| "answers": [ | |
| {"question_number": i + 1, "answer": letter} | |
| for i, letter in enumerate(letters) | |
| ], | |
| } | |
| ) | |
| return {"students": students} | |
| def table_to_teacher_answers(rows: list[list]) -> dict: | |
| key_group, _ = _group_rows(rows) | |
| if key_group is None: | |
| return {"answers": []} | |
| letters = _collect_letters(key_group) | |
| return { | |
| "answers": [ | |
| { | |
| "question_number": i + 1, | |
| "correct_answer": letter, | |
| "explanation": None, | |
| } | |
| for i, letter in enumerate(letters) | |
| if letter is not None | |
| ] | |
| } | |
| def table_to_data(rows: list[list], data_type: str) -> dict: | |
| if data_type == "student_answers": | |
| return table_to_student_answers(rows) | |
| if data_type == "teacher_answers": | |
| return table_to_teacher_answers(rows) | |
| raise ValueError(f"Native table parser does not support data_type={data_type}") | |