2025-04-23 17:48:46 +09:00
|
|
|
from datetime import datetime
|
|
|
|
|
import difflib
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import os
|
|
|
|
|
from lxml import etree as ET
|
|
|
|
|
import re
|
|
|
|
|
from difflib import SequenceMatcher
|
|
|
|
|
import pandas as pd
|
|
|
|
|
import base64
|
2025-05-12 18:24:35 +09:00
|
|
|
import math
|
2025-05-15 18:06:06 +09:00
|
|
|
from itertools import chain
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# from xpathSearch import XMLPathHandler
|
|
|
|
|
|
|
|
|
|
class XMLScorer:
|
|
|
|
|
# 채점 기준 경로 초기화
|
|
|
|
|
def __init__(self, scoring_criteria_path):
|
|
|
|
|
# 채점 기준 로드
|
|
|
|
|
self.scoring_criteria = self._load_scoring_criteria(scoring_criteria_path)
|
|
|
|
|
self.total_score = 0
|
|
|
|
|
self.partial_score = 0
|
|
|
|
|
self.typo_score = 0
|
|
|
|
|
|
|
|
|
|
def set_typo_score(self, score):
|
|
|
|
|
self.typo_score = score
|
|
|
|
|
|
|
|
|
|
def get_typo_score(self):
|
|
|
|
|
return self.typo_score
|
|
|
|
|
|
|
|
|
|
# 채점 기준파일 로드(JSON 파일)
|
|
|
|
|
def _load_scoring_criteria(self, file_path):
|
|
|
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
return json.load(f)
|
|
|
|
|
|
2025-05-12 18:24:35 +09:00
|
|
|
# mm to pt
|
|
|
|
|
def convert_mm_to_pt(self, mm):
|
|
|
|
|
one_mm_per_pt = 2.83465
|
2025-07-02 17:02:50 +09:00
|
|
|
hwp_scale = 100
|
|
|
|
|
pt = math.trunc(mm * one_mm_per_pt * hwp_scale)
|
2025-05-12 18:24:35 +09:00
|
|
|
return pt
|
2025-04-23 17:48:46 +09:00
|
|
|
|
2025-07-02 17:02:50 +09:00
|
|
|
def convert_pt_to_mm(self, pt):
|
|
|
|
|
one_mm_per_pt = 2.83465
|
|
|
|
|
hwp_scale = 100
|
|
|
|
|
mm = round(pt / (one_mm_per_pt * hwp_scale), 1)
|
|
|
|
|
return mm
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 유사한 텍스트 찾기
|
2025-05-20 18:02:04 +09:00
|
|
|
def find_similar_text(self, root, target_text, xml_type, threshold=0.7):
|
2025-04-23 17:48:46 +09:00
|
|
|
"""
|
|
|
|
|
전체 문서에서 유사한 텍스트를 찾아 반환
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
root (_type_): xml root element 객체
|
|
|
|
|
target_text (_type_): 찾을 텍스트
|
|
|
|
|
threshold (float, optional): 유사도 설정 Defaults to 0.3.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: 유사도 기준을 만족하는 텍스트
|
|
|
|
|
"""
|
|
|
|
|
# 전체 텍스트 추출
|
|
|
|
|
# all_text = root.xpath(f"//CHAR/text()")
|
|
|
|
|
# all_text.append(root.xpath(f"//TEXTART/@text"))
|
|
|
|
|
|
|
|
|
|
namespaces = {
|
|
|
|
|
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
|
|
|
|
'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart'
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-20 18:02:04 +09:00
|
|
|
if xml_type == "hml":
|
|
|
|
|
all_text = root.xpath(f"//BODY//text() | //TEXTART/@Text") if root is not None else []
|
2025-05-19 17:50:18 +09:00
|
|
|
|
2025-07-09 17:10:59 +09:00
|
|
|
elif xml_type == "chart":
|
2025-05-20 18:02:04 +09:00
|
|
|
all_text = root.xpath(f"//c:chart//text()", namespaces=namespaces) if root is not None else []
|
2025-04-23 17:48:46 +09:00
|
|
|
|
2025-07-09 17:10:59 +09:00
|
|
|
else:
|
|
|
|
|
all_text = []
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 유사도 비교
|
|
|
|
|
max_score = 0
|
|
|
|
|
similar_text = ''
|
|
|
|
|
|
|
|
|
|
for text in all_text:
|
|
|
|
|
score = SequenceMatcher(None, target_text, text).ratio()
|
|
|
|
|
|
|
|
|
|
if score > max_score:
|
|
|
|
|
max_score = score
|
|
|
|
|
similar_text = text
|
|
|
|
|
|
|
|
|
|
if max_score >= threshold:
|
|
|
|
|
return similar_text
|
|
|
|
|
else:
|
|
|
|
|
return target_text
|
|
|
|
|
|
|
|
|
|
# 정답 비교 및 점수 계산
|
|
|
|
|
def evaluate_answer(self, scoring, user_answer, right_answer, points,
|
|
|
|
|
method="equal", tolerance=0):
|
|
|
|
|
|
|
|
|
|
scoring['user_answer'] = user_answer
|
|
|
|
|
|
|
|
|
|
is_correct = False
|
|
|
|
|
|
2025-05-23 17:18:55 +09:00
|
|
|
# 일치 여부 확인
|
2025-04-23 17:48:46 +09:00
|
|
|
if method == "equal":
|
|
|
|
|
is_correct = (user_answer == right_answer)
|
2025-05-23 17:18:55 +09:00
|
|
|
|
|
|
|
|
# 정답이 오차범위가 필요한 경우
|
2025-04-23 17:48:46 +09:00
|
|
|
elif method == "tolerance":
|
|
|
|
|
if isinstance(user_answer, dict) and isinstance(right_answer, dict):
|
|
|
|
|
is_correct = all(abs(user_answer[k] - right_answer[k]) <= tolerance for k in right_answer)
|
|
|
|
|
else:
|
|
|
|
|
is_correct = abs(user_answer - right_answer) <= tolerance
|
2025-05-23 17:18:55 +09:00
|
|
|
|
|
|
|
|
# 정답이 포함되어 있는 경우
|
2025-04-23 17:48:46 +09:00
|
|
|
elif method == "in":
|
|
|
|
|
is_correct = user_answer in right_answer
|
2025-05-23 17:18:55 +09:00
|
|
|
|
|
|
|
|
# 정답을 부분점수로 계산(특수문자, 한자)
|
2025-04-25 17:57:04 +09:00
|
|
|
elif method == "partial_score":
|
|
|
|
|
# 부분 점수 계산
|
|
|
|
|
is_correct = isinstance(user_answer, (int, float)) and user_answer <= right_answer
|
|
|
|
|
points = min(points, user_answer)
|
2025-04-23 17:48:46 +09:00
|
|
|
else:
|
|
|
|
|
raise ValueError(f"Unknown comparison method: {method}")
|
|
|
|
|
|
|
|
|
|
if is_correct:
|
|
|
|
|
scoring['points'] = points
|
|
|
|
|
self.total_score += points
|
|
|
|
|
self.partial_score += points
|
|
|
|
|
else:
|
|
|
|
|
scoring['points'] = 0
|
|
|
|
|
|
|
|
|
|
# 하나의 XML 파일 채점
|
|
|
|
|
def _score_xml_file(self, xml_file, chart_xml):
|
2025-06-04 18:00:06 +09:00
|
|
|
|
2025-06-27 16:22:18 +09:00
|
|
|
def parse_pages_by_bookmark(root):
|
|
|
|
|
"""
|
|
|
|
|
P/TEXT/BOOKMARK 구조를 가진 XML에서 페이지 구간별 <p> 요소를 파싱하여 반환
|
|
|
|
|
"""
|
|
|
|
|
pages = {}
|
|
|
|
|
all_p_tags = root.xpath('//P')
|
|
|
|
|
|
|
|
|
|
current_page = None
|
|
|
|
|
page_start_index = None
|
|
|
|
|
|
|
|
|
|
for i, p in enumerate(all_p_tags):
|
|
|
|
|
# BOOKMARK가 존재하는지 확인
|
|
|
|
|
bookmark = p.xpath('./TEXT/BOOKMARK')
|
|
|
|
|
if bookmark:
|
|
|
|
|
name = bookmark[0].get('Name')
|
|
|
|
|
if name and name.endswith('_start'):
|
|
|
|
|
current_page = name.replace('_start', '')
|
|
|
|
|
page_start_index = i
|
|
|
|
|
elif name and name.endswith('_end') and current_page is not None:
|
|
|
|
|
page_end_index = i
|
|
|
|
|
page_content = all_p_tags[page_start_index:page_end_index + 1]
|
|
|
|
|
pages[current_page] = page_content
|
|
|
|
|
current_page = None
|
|
|
|
|
page_start_index = None
|
|
|
|
|
|
|
|
|
|
return pages
|
|
|
|
|
|
2025-06-04 18:00:06 +09:00
|
|
|
def extract_char_text_from_p(p_element):
|
|
|
|
|
"""
|
|
|
|
|
주어진 <P> 요소에서 모든 자손 <CHAR>의 텍스트를 추출해 문자열 리스트로 반환합니다.
|
|
|
|
|
"""
|
|
|
|
|
full_text = []
|
|
|
|
|
for p in p_element:
|
|
|
|
|
char_elements = p.xpath('.//CHAR')
|
|
|
|
|
combined_text = ''.join([char.text for char in char_elements if char.text])
|
|
|
|
|
no_space_text = re.sub(r'\s+', '', combined_text) # 공백 문자 제거
|
|
|
|
|
full_text.append(no_space_text)
|
|
|
|
|
return full_text
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
try:
|
|
|
|
|
tree = ET.parse(xml_file)
|
|
|
|
|
root = tree.getroot()
|
|
|
|
|
|
2025-06-27 16:22:18 +09:00
|
|
|
# XML문서 페이지 파싱 전처리
|
|
|
|
|
pages = parse_pages_by_bookmark(root)
|
2025-07-02 17:02:50 +09:00
|
|
|
# print("🚩Pages : ", pages)
|
2025-06-27 16:22:18 +09:00
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 네임스페이스 정의
|
|
|
|
|
namespaces = {
|
|
|
|
|
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
|
|
|
|
'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 차트 XML 파일이 없는 경우 0점 채점을 위헤 빈 XML 생성
|
|
|
|
|
if chart_xml is None:
|
|
|
|
|
chart_tree = ET.fromstring('<xml></xml>')
|
|
|
|
|
else:
|
|
|
|
|
chart_tree = ET.fromstring(chart_xml)
|
|
|
|
|
|
|
|
|
|
# 결과값을 Dictionary로 저장
|
|
|
|
|
# 하나의 xml파일 = 수험생 한명의 답안지
|
|
|
|
|
onePersonResult = {
|
|
|
|
|
'filename': os.path.basename(xml_file),
|
|
|
|
|
'score_results': [],
|
|
|
|
|
'total_score': 0,
|
|
|
|
|
'partial_scores': []
|
|
|
|
|
}
|
2025-06-27 16:22:18 +09:00
|
|
|
print(f"🔜File name: {onePersonResult['filename']}")
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
self.total_score = 0
|
|
|
|
|
for section_id, section in self.scoring_criteria.items():
|
|
|
|
|
self.partial_score = 0
|
|
|
|
|
for criterion_id, criterion in section.items():
|
|
|
|
|
id = criterion_id
|
|
|
|
|
xpath = criterion.get('path', None)
|
2025-04-25 17:57:04 +09:00
|
|
|
xpath2 = criterion.get('path2', None)
|
2025-05-12 18:24:35 +09:00
|
|
|
xpath3 = criterion.get('path3', None)
|
2025-05-16 17:58:33 +09:00
|
|
|
chart_xpath = criterion.get('chart_xpath', None)
|
2025-04-23 17:48:46 +09:00
|
|
|
search_value = criterion.get('searchValue', None)
|
|
|
|
|
right_answer = criterion.get('value', None)
|
|
|
|
|
points = criterion.get('points', 0)
|
|
|
|
|
category = criterion.get('category', None)
|
|
|
|
|
item = criterion.get('item', None)
|
2025-05-15 18:06:06 +09:00
|
|
|
option = criterion.get('option', None)
|
2025-04-23 17:48:46 +09:00
|
|
|
similar_text = None
|
|
|
|
|
|
|
|
|
|
# search_value가 있는 경우
|
|
|
|
|
if search_value is not None:
|
2025-05-20 18:02:04 +09:00
|
|
|
if xpath or xpath2:
|
|
|
|
|
similar_text = self.find_similar_text(root, search_value, xml_type="hml")
|
|
|
|
|
xpath = xpath.replace('{searchValue}', similar_text) if xpath else ""
|
|
|
|
|
xpath2 = xpath2.replace('{searchValue}', similar_text) if xpath2 else ""
|
|
|
|
|
if chart_xpath:
|
|
|
|
|
similar_text = self.find_similar_text(chart_tree, search_value, xml_type="chart")
|
|
|
|
|
chart_xpath = chart_xpath.replace('{searchValue}', similar_text) if chart_xpath else ""
|
2025-05-15 18:06:06 +09:00
|
|
|
|
|
|
|
|
if option:
|
|
|
|
|
xpath = xpath.replace('{option}', option) if xpath else ""
|
|
|
|
|
xpath2 = xpath2.replace('{option}', option) if xpath2 else ""
|
2025-05-16 17:58:33 +09:00
|
|
|
chart_xpath = chart_xpath.replace('{option}', option) if chart_xpath else ""
|
2025-05-23 17:18:55 +09:00
|
|
|
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 문항 별 채점 결과 저장
|
|
|
|
|
scoring = {
|
|
|
|
|
'section': section_id,
|
|
|
|
|
'id': id,
|
|
|
|
|
'category': category, # 채점 분류
|
|
|
|
|
'item': item, # 채점 항목
|
|
|
|
|
'right_answer': right_answer, # 정답
|
|
|
|
|
'user_answer': None, # 실제 작성 답안
|
|
|
|
|
'points': 0, # 점수
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
if (category or "") == "PageSetting":
|
2025-04-23 17:48:46 +09:00
|
|
|
items = root.xpath(xpath)
|
|
|
|
|
error_range = criterion.get('tolerance', 0)
|
2025-05-21 17:57:41 +09:00
|
|
|
|
|
|
|
|
right_answer = {
|
|
|
|
|
'Top' : float(right_answer.get("Top", 0)),
|
|
|
|
|
'Bottom' : float(right_answer.get("Bottom", 0)),
|
|
|
|
|
'Left' : float(right_answer.get("Left", 0)),
|
|
|
|
|
'Right' : float(right_answer.get("Right", 0)),
|
|
|
|
|
'Header' : float(right_answer.get("Header", 0)),
|
|
|
|
|
'Footer' : float(right_answer.get("Footer", 0)),
|
|
|
|
|
'Gutter' : float(right_answer.get("Gutter", 0)),
|
|
|
|
|
}
|
|
|
|
|
right_answer = {
|
|
|
|
|
k: self.convert_mm_to_pt(v)
|
|
|
|
|
for k, v in right_answer.items()
|
|
|
|
|
}
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
for item in items:
|
|
|
|
|
user_answer = {
|
2025-05-21 17:57:41 +09:00
|
|
|
'Top' : float(item.get("Top", 0)),
|
|
|
|
|
'Bottom' : float(item.get("Bottom", 0)),
|
|
|
|
|
'Left' : float(item.get("Left", 0)),
|
|
|
|
|
'Right' : float(item.get("Right", 0)),
|
|
|
|
|
'Header' : float(item.get("Header", 0)),
|
|
|
|
|
'Footer' : float(item.get("Footer", 0)),
|
|
|
|
|
'Gutter' : float(item.get("Gutter", 0)),
|
2025-04-23 17:48:46 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="tolerance", tolerance=error_range)
|
|
|
|
|
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
|
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "BasicSetting":
|
2025-05-20 18:02:04 +09:00
|
|
|
# FontName, FontSize, Alignment, LineSpacing
|
|
|
|
|
# 해당 속성의 요소(텍스트)가 문서 내부에 존재하면 정답처리
|
|
|
|
|
|
|
|
|
|
matches = set()
|
|
|
|
|
|
|
|
|
|
# P 태그 순회
|
|
|
|
|
for p_tag in root.xpath(".//P"):
|
|
|
|
|
parashape = p_tag.get("ParaShape")
|
|
|
|
|
|
|
|
|
|
for text_tag in p_tag.xpath(".//TEXT"):
|
|
|
|
|
charshape = text_tag.get("CharShape")
|
|
|
|
|
|
|
|
|
|
if parashape is not None and charshape is not None:
|
|
|
|
|
matches.add((parashape, charshape))
|
|
|
|
|
|
|
|
|
|
# 출력
|
|
|
|
|
for para, char in matches:
|
|
|
|
|
# print(f"ParaShape = {para}, CharShape = {char}")
|
|
|
|
|
font_id = root.xpath(f"//CHARSHAPE[@Id='{char}']/FONTID/@Hangul")
|
|
|
|
|
font_name = root.xpath(f"//FONTFACE[@Lang='Hangul']/FONT[@Id='{font_id[0]}']/@Name")
|
|
|
|
|
|
|
|
|
|
user_answer = {
|
|
|
|
|
'FontName': font_name[0],
|
|
|
|
|
'FontSize': root.xpath(f"//CHARSHAPE[@Id='{char}']/@Height")[0],
|
|
|
|
|
'Alignment': root.xpath(f"//PARASHAPE[@Id='{para}']/@Align")[0],
|
|
|
|
|
'LineSpacing': root.xpath(f"//PARASHAPE[@Id='{para}']/PARAMARGIN/@LineSpacing")[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 정답과 수험자 답안 비교
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
|
|
|
|
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
2025-04-23 17:48:46 +09:00
|
|
|
|
2025-05-19 17:50:18 +09:00
|
|
|
# 1, 2페이지 모두 정답이어야 함
|
|
|
|
|
elif (category or "") == "PageNumber":
|
|
|
|
|
items = root.xpath(xpath) if xpath else []
|
|
|
|
|
|
|
|
|
|
all_match = True
|
|
|
|
|
for item in chain(items):
|
|
|
|
|
user_answer = item
|
|
|
|
|
if right_answer != user_answer:
|
|
|
|
|
all_match = False
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if all_match:
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
|
|
|
|
else:
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, 0, method="equal")
|
|
|
|
|
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 오타 감점 부분은 미리 계산 하고, 이후 점수만 계산
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "오타감점":
|
2025-04-23 17:48:46 +09:00
|
|
|
points = self.get_typo_score()
|
|
|
|
|
self.total_score += points
|
|
|
|
|
self.partial_score += points
|
|
|
|
|
scoring['points'] = points
|
|
|
|
|
|
2025-05-23 17:18:55 +09:00
|
|
|
# 테이블의 경우 모든 셀에 요구사항이 적용되어야 정답처리
|
|
|
|
|
elif (category or "") == "TableAnswer":
|
2025-05-16 17:58:33 +09:00
|
|
|
items = root.xpath(xpath) if xpath else []
|
2025-05-15 18:06:06 +09:00
|
|
|
items2 = root.xpath(xpath2) if xpath2 else []
|
2025-05-23 17:18:55 +09:00
|
|
|
|
|
|
|
|
def is_all_match(item_list):
|
|
|
|
|
return item_list and all(item == right_answer for item in item_list)
|
|
|
|
|
## 위 코드와 동일한 기능(풀어서 설명)
|
|
|
|
|
# 리스트가 비어 있으면 False 반환
|
|
|
|
|
# if not item_list:
|
|
|
|
|
# return False
|
|
|
|
|
|
|
|
|
|
# # 리스트의 모든 항목이 right_answer와 같은지 검사
|
|
|
|
|
# for item in item_list:
|
|
|
|
|
# if item != right_answer:
|
|
|
|
|
# return False # 하나라도 다르면 False 반환
|
|
|
|
|
|
|
|
|
|
# return True # 전부 일치하면 True 반환
|
|
|
|
|
|
|
|
|
|
if is_all_match(items):
|
|
|
|
|
user_answer = right_answer
|
|
|
|
|
elif is_all_match(items2):
|
|
|
|
|
user_answer = right_answer
|
|
|
|
|
else:
|
|
|
|
|
user_answer = ""
|
|
|
|
|
points = 0
|
|
|
|
|
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points)
|
2025-05-21 17:57:41 +09:00
|
|
|
|
2025-07-09 17:10:59 +09:00
|
|
|
# [1-16] ◈ 행사안내 ◈
|
|
|
|
|
# 특수문자와 글자의 속성이 같고 문서 내부에 '행사안내'와 같은 문자가 있을 경우
|
2025-07-29 18:00:04 +09:00
|
|
|
# 유사도 문제로 의도치 않은 다른 부분의 텍스트 속성이 채점되는것을 방지하고자
|
2025-07-09 17:10:59 +09:00
|
|
|
# 해당 문자를 포함하는 모든 문단의 속성을 판단해
|
|
|
|
|
# 정렬값이 정답과 일치하는 경우 정답으로 채점
|
|
|
|
|
elif (category or "") == "Align":
|
|
|
|
|
match_str = criterion.get('match_str', None)
|
|
|
|
|
|
|
|
|
|
xpath = xpath.replace('{match_str}', match_str)
|
|
|
|
|
items = root.xpath(xpath)
|
|
|
|
|
|
|
|
|
|
for item in items:
|
|
|
|
|
user_answer = item
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points)
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
2025-07-29 18:00:04 +09:00
|
|
|
|
|
|
|
|
elif (category or "") == "majorGridlines":
|
|
|
|
|
# 줄/칸 전환여부 확인
|
2025-08-29 18:22:31 +09:00
|
|
|
# table_col_count = root.xpath("//SECTION[2]//TABLE/@ColCount")
|
|
|
|
|
table_col_count = root.xpath("//TABLE/@ColCount")
|
2025-07-29 18:00:04 +09:00
|
|
|
|
2025-08-29 18:22:31 +09:00
|
|
|
# print("🟡테이블 열 개수: ", int(table_col_count[0]) if table_col_count else 0)
|
2025-07-29 18:00:04 +09:00
|
|
|
|
|
|
|
|
chart_ser_count = chart_tree.xpath("count(//c:ser)", namespaces=namespaces) if chart_xpath else 0
|
|
|
|
|
|
2025-08-29 18:22:31 +09:00
|
|
|
# print("🟡차트 데이터 개수: ", int(chart_ser_count) if isinstance(chart_ser_count, (int, float)) else 0)
|
2025-07-29 18:00:04 +09:00
|
|
|
|
|
|
|
|
isXYtransposed = False
|
|
|
|
|
if table_col_count and chart_ser_count:
|
2025-08-29 18:22:31 +09:00
|
|
|
if int(chart_ser_count) > int(table_col_count[0])-1:
|
2025-07-29 18:00:04 +09:00
|
|
|
isXYtransposed = True
|
|
|
|
|
|
|
|
|
|
# 값 축 주눈금선 존재하는지 여부 확인
|
|
|
|
|
items = chart_tree.xpath(chart_xpath, namespaces=namespaces) if chart_xpath else []
|
|
|
|
|
|
|
|
|
|
for item in items:
|
|
|
|
|
# item이 존재하면 True, 없으면 False
|
|
|
|
|
user_answer = (item is not None) and isXYtransposed
|
2025-07-09 17:10:59 +09:00
|
|
|
|
2025-07-29 18:00:04 +09:00
|
|
|
# 정답과 수험자 답안 비교
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points)
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
2025-05-23 17:18:55 +09:00
|
|
|
# 정답이 하나인 경우
|
|
|
|
|
# elif (category or "") == "OneAnswer":
|
|
|
|
|
elif (category or "") in ["OneAnswer", "ChartOneAnswer"]:
|
|
|
|
|
items = root.xpath(xpath) if xpath else []
|
|
|
|
|
items2 = root.xpath(xpath2) if xpath2 else []
|
|
|
|
|
|
|
|
|
|
# 차트 XML에서 정답을 찾는 경우
|
|
|
|
|
# 차트 종류가
|
|
|
|
|
# 세로막대형이면 x축이 카테고리(catAx) y축이 값(valAx)
|
|
|
|
|
# 가로막대형이면 x축이 값(valAx) y축이 카테고리(catAx)
|
|
|
|
|
if category == "ChartOneAnswer":
|
|
|
|
|
# 하드코딩이라 [2-45문항] 변경시 수정 필요
|
|
|
|
|
# chart_type = self.scoring_criteria["2"]["45"]["chart_type"].replace(" ","")
|
|
|
|
|
|
|
|
|
|
# chart_type 변수의 경우 45번 문항을 먼저 채점하므로
|
|
|
|
|
# xy축의 변경이 필요한 53~58번 문항 채점시에 chart_type변수에 차트모양의 정보는 입력 되어있음
|
|
|
|
|
|
|
|
|
|
# 가로 차트일 경우에만 x축과 y축을 바꿔줌
|
|
|
|
|
# 세로, 꺾은선, 원형 차트의 경우 그대로 사용
|
|
|
|
|
if "가로" in chart_type:
|
2025-05-26 17:46:45 +09:00
|
|
|
if "catAx" in chart_xpath:
|
|
|
|
|
chart_xpath = chart_xpath.replace("catAx", "valAx")
|
2025-06-24 16:57:51 +09:00
|
|
|
elif "valAx" in chart_xpath:
|
2025-05-26 17:46:45 +09:00
|
|
|
chart_xpath = chart_xpath.replace("valAx", "catAx")
|
2025-05-23 17:18:55 +09:00
|
|
|
|
|
|
|
|
chart_items = chart_tree.xpath(chart_xpath, namespaces=namespaces) if chart_xpath else []
|
|
|
|
|
|
2025-05-16 17:58:33 +09:00
|
|
|
for item in chain(items, items2, chart_items):
|
2025-05-26 17:46:45 +09:00
|
|
|
user_answer = item.replace(" ", "") if isinstance(item, str) else item
|
|
|
|
|
right_answer = right_answer.replace(" ", "")
|
|
|
|
|
|
2025-05-23 17:18:55 +09:00
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points)
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
2025-07-02 17:02:50 +09:00
|
|
|
|
|
|
|
|
# [2-6] 테두리 이중실선 1.00mm
|
|
|
|
|
elif (category or "") == "LineShape":
|
|
|
|
|
line_shapes = root.xpath(xpath) if xpath else []
|
2025-05-23 17:18:55 +09:00
|
|
|
|
2025-07-02 17:02:50 +09:00
|
|
|
user_answer = {
|
|
|
|
|
'Style': None,
|
|
|
|
|
'Width': None
|
|
|
|
|
}
|
2025-05-13 19:46:19 +09:00
|
|
|
|
2025-07-02 17:02:50 +09:00
|
|
|
for line_shape in line_shapes:
|
|
|
|
|
style = line_shape.get("Style")
|
|
|
|
|
width = line_shape.get("Width")
|
|
|
|
|
|
|
|
|
|
user_answer['Style'] = style
|
|
|
|
|
user_answer['Width'] = width
|
|
|
|
|
|
2025-05-13 19:46:19 +09:00
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points)
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
2025-07-02 17:02:50 +09:00
|
|
|
|
2025-05-13 19:46:19 +09:00
|
|
|
# 사용자 입력값이 mm단위인 경우
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "mmSize":
|
2025-05-12 18:24:35 +09:00
|
|
|
items = root.xpath(xpath)
|
2025-05-20 18:02:04 +09:00
|
|
|
# 오차범위 설정
|
2025-05-23 17:18:55 +09:00
|
|
|
# 한글 프로그램 내부에서 드물게 0mm이지만 1pt로 저장되는 경우가 있음
|
2025-05-20 18:02:04 +09:00
|
|
|
#
|
|
|
|
|
# XML파일의 요소 옵션값은 내부적으로 1=0.01pt
|
|
|
|
|
# 이 경우를 대비하여 tolerance를 10으로 설정 (1pt=약0.04mm 만큼의 오차 혀용)
|
|
|
|
|
error_range = criterion.get('tolerance', 10)
|
2025-05-12 18:24:35 +09:00
|
|
|
|
2025-05-21 17:57:41 +09:00
|
|
|
# JSON 파일 value키값에 mm나 공백이 입력될 경우 제거
|
|
|
|
|
# 예) "80.2 mm" >> 80.2 로 변환
|
|
|
|
|
float_string = right_answer.strip().replace("mm", "")
|
|
|
|
|
right_answer = self.convert_mm_to_pt(float(float_string))
|
|
|
|
|
|
2025-06-04 18:00:06 +09:00
|
|
|
if not items:
|
|
|
|
|
scoring['points'] = 0
|
|
|
|
|
else:
|
|
|
|
|
for item in items:
|
|
|
|
|
user_answer = float(item)
|
2025-05-21 17:57:41 +09:00
|
|
|
|
2025-06-04 18:00:06 +09:00
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="tolerance", tolerance=error_range)
|
|
|
|
|
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
2025-05-12 18:24:35 +09:00
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "ParaShape":
|
2025-04-25 17:57:04 +09:00
|
|
|
items = root.xpath(xpath)
|
|
|
|
|
|
|
|
|
|
for item in items:
|
|
|
|
|
user_answer = {
|
|
|
|
|
'Left': float(item.get('Left', 0)) / 200,
|
|
|
|
|
'Indent': float(item.get('Indent', 0)) / -200,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 정답과 수험자 답안 비교
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
|
|
|
|
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# Boolean 타입 정답인 경우
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "Boolean":
|
2025-05-16 17:58:33 +09:00
|
|
|
items = root.xpath(xpath) if xpath else False
|
2025-05-14 18:01:36 +09:00
|
|
|
items2 = root.xpath(xpath2) if xpath2 else False
|
2025-05-16 17:58:33 +09:00
|
|
|
chart_items = chart_tree.xpath(chart_xpath, namespaces=namespaces) if chart_xpath else False
|
2025-05-14 18:01:36 +09:00
|
|
|
|
2025-05-16 17:58:33 +09:00
|
|
|
user_answer = bool( items or items2 or chart_items )
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points)
|
|
|
|
|
|
|
|
|
|
# 채점기준표 파일에 작성된 rgb값을 그대로 읽어와 HML파일 요소의 int형 rgb값과 비교
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "Color":
|
|
|
|
|
items = root.xpath(xpath) if xpath else []
|
|
|
|
|
items2 = root.xpath(xpath2) if xpath2 else []
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
rgb_text = right_answer
|
2025-05-21 17:57:41 +09:00
|
|
|
|
|
|
|
|
# 정규식을 이용해 숫자만 리스트로 추출
|
|
|
|
|
numbers = re.findall(r'\d+', rgb_text)
|
|
|
|
|
r, g, b = map(int, numbers) if len(numbers) == 3 else None
|
|
|
|
|
|
|
|
|
|
# 콤마(,)로 구분된 문자열을 정수형으로 변환
|
|
|
|
|
# r, g, b = map(int, rgb_text.split(','))
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
rgb_int = (b << 16) + (g << 8) + r
|
|
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
# items, items2를 순차적으로 순회
|
|
|
|
|
for item in chain(items, items2):
|
2025-04-23 17:48:46 +09:00
|
|
|
user_answer = int(item)
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, rgb_int, points, method="equal")
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
|
|
|
|
|
2025-04-25 17:57:04 +09:00
|
|
|
# 문단 첫글자 장식 채점
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "TwoLineSize":
|
2025-04-23 17:48:46 +09:00
|
|
|
items = root.xpath(xpath)
|
|
|
|
|
error_range = criterion.get('tolerance', 0)
|
|
|
|
|
for item in items:
|
|
|
|
|
user_answer = {
|
|
|
|
|
"Height": int(item.get('Height', 0)),
|
|
|
|
|
"Width": int(item.get('Width', 0))
|
|
|
|
|
}
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="tolerance", tolerance=error_range)
|
|
|
|
|
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
|
|
|
|
|
2025-04-25 17:57:04 +09:00
|
|
|
# 폰트명
|
2025-05-21 17:57:41 +09:00
|
|
|
elif (category or "") in ["FontName", "TableFontName"]:
|
2025-05-15 18:06:06 +09:00
|
|
|
charshape_list = root.xpath(xpath)
|
2025-05-21 17:57:41 +09:00
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
# 문자속성이 없는 경우
|
|
|
|
|
if not charshape_list:
|
|
|
|
|
user_answer = ""
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
|
|
|
|
else:
|
2025-05-21 17:57:41 +09:00
|
|
|
require_all_match = (category == "TableFontName")
|
|
|
|
|
any_match = False
|
|
|
|
|
all_match = True
|
2025-05-23 17:18:55 +09:00
|
|
|
matched_user_answer = None # 일치하는 user_answer를 기억
|
2025-05-21 17:57:41 +09:00
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
for charshape_id in charshape_list:
|
|
|
|
|
font_id = root.xpath(f"//CHARSHAPE[@Id='{charshape_id}']/FONTID/@Hangul")
|
2025-05-21 17:57:41 +09:00
|
|
|
if not font_id:
|
|
|
|
|
all_match = False
|
|
|
|
|
continue
|
|
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
font_name = root.xpath(f"//FONTFACE[@Lang='Hangul']/FONT[@Id='{font_id[0]}']/@Name")
|
2025-05-26 17:46:45 +09:00
|
|
|
|
2025-05-21 17:57:41 +09:00
|
|
|
if not font_name:
|
|
|
|
|
all_match = False
|
|
|
|
|
continue
|
2025-04-23 17:48:46 +09:00
|
|
|
|
2025-07-02 17:02:50 +09:00
|
|
|
# 공백 제거
|
|
|
|
|
user_answer = font_name[0].replace(" ", "")
|
|
|
|
|
right_answer = right_answer.replace(" ","")
|
|
|
|
|
|
2025-05-21 17:57:41 +09:00
|
|
|
# 접두어 제거
|
2025-05-15 18:06:06 +09:00
|
|
|
if right_answer in ["견고딕", "중고딕"]:
|
|
|
|
|
user_answer = user_answer.replace("한양", "")
|
|
|
|
|
|
2025-05-21 17:57:41 +09:00
|
|
|
if user_answer == right_answer:
|
|
|
|
|
any_match = True
|
2025-05-23 17:18:55 +09:00
|
|
|
matched_user_answer = user_answer
|
2025-05-21 17:57:41 +09:00
|
|
|
else:
|
2025-05-15 18:06:06 +09:00
|
|
|
all_match = False
|
2025-05-21 17:57:41 +09:00
|
|
|
if require_all_match:
|
|
|
|
|
break
|
2025-05-15 18:06:06 +09:00
|
|
|
|
2025-05-21 17:57:41 +09:00
|
|
|
if require_all_match:
|
|
|
|
|
score = points if all_match else 0
|
2025-05-23 17:18:55 +09:00
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, score)
|
2025-05-15 18:06:06 +09:00
|
|
|
else:
|
2025-05-21 17:57:41 +09:00
|
|
|
score = points if any_match else 0
|
2025-05-23 17:18:55 +09:00
|
|
|
self.evaluate_answer(scoring, matched_user_answer if any_match else "", right_answer, score)
|
2025-04-23 17:48:46 +09:00
|
|
|
|
2025-04-25 17:57:04 +09:00
|
|
|
# 폰트 속성
|
2025-05-15 18:06:06 +09:00
|
|
|
elif (category or "") == "FontAttribute":
|
2025-05-19 17:50:18 +09:00
|
|
|
# 하이퍼링크 처리
|
2025-06-04 18:00:06 +09:00
|
|
|
|
|
|
|
|
# 1. 하이퍼링크를 포함하는 P요소를 가져옴
|
|
|
|
|
# 2. 그 P요소의 자손 CHAR태그에 있는 텍스트를 하나의 문자열로 변환
|
|
|
|
|
# 3. P요소의 문자열과 채점하려는 문자열이 일치하는지 확인
|
|
|
|
|
hyperlink_xpath = criterion.get('hyperlink_ptag', None)
|
|
|
|
|
hyperlink_ptag = root.xpath(hyperlink_xpath) if hyperlink_xpath else None
|
|
|
|
|
|
|
|
|
|
p_tag_text_list = extract_char_text_from_p(hyperlink_ptag) if hyperlink_ptag else []
|
|
|
|
|
hyperlink_text = search_value.replace(" ", "") if search_value else ""
|
|
|
|
|
|
|
|
|
|
# search_value가 hyperlink문자열에 포함되어 있는지 확인
|
|
|
|
|
# search_value가 hyperlink인 경우와 아닌경우를 구분해 채점
|
|
|
|
|
search_in_hyperlink = False
|
|
|
|
|
if hyperlink_text and any(hyperlink_text in text for text in p_tag_text_list):
|
|
|
|
|
search_in_hyperlink = True
|
|
|
|
|
else:
|
|
|
|
|
search_in_hyperlink = False
|
2025-05-19 17:50:18 +09:00
|
|
|
|
|
|
|
|
# hyperlink가 아닌 경우(일반적인 텍스트 일 경우)
|
2025-06-04 18:00:06 +09:00
|
|
|
# 하이퍼링크를 포함한 P태그가 없거나 search_value값이 하이퍼링크텍스트에 포함되어 있지 않을 경우
|
|
|
|
|
if not hyperlink_ptag or not search_in_hyperlink:
|
2025-05-26 17:46:45 +09:00
|
|
|
charshape_list = root.xpath(xpath)
|
|
|
|
|
if not charshape_list:
|
2025-05-19 17:50:18 +09:00
|
|
|
charshape = None
|
2025-04-25 17:57:04 +09:00
|
|
|
user_answer = None
|
2025-05-19 17:50:18 +09:00
|
|
|
else:
|
2025-05-26 17:46:45 +09:00
|
|
|
for charshape in charshape_list:
|
|
|
|
|
font_attribute = charshape.find(right_answer)
|
|
|
|
|
if font_attribute is not None:
|
|
|
|
|
user_answer = font_attribute.tag
|
|
|
|
|
else:
|
|
|
|
|
user_answer = None
|
2025-05-19 17:50:18 +09:00
|
|
|
|
2025-05-26 17:46:45 +09:00
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
|
|
|
|
|
|
|
|
|
if scoring['points'] > 0:
|
|
|
|
|
break
|
2025-05-19 17:50:18 +09:00
|
|
|
|
|
|
|
|
# 하이퍼링크인 경우
|
2025-06-04 18:00:06 +09:00
|
|
|
# elif hyperlink_ptag and search_in_hyperlink:
|
|
|
|
|
else:
|
|
|
|
|
p_elements = hyperlink_ptag
|
2025-05-19 17:50:18 +09:00
|
|
|
|
|
|
|
|
for p in p_elements:
|
2025-05-23 17:18:55 +09:00
|
|
|
# 수험자가 입력한 텍스트 중 하이퍼링크가 들어간 문단의 모든 텍스트를 가져와
|
|
|
|
|
# 채점하고자 하는 (정답) 하이퍼링크 텍스트와 시작 위치를 비교
|
|
|
|
|
# (예시)
|
|
|
|
|
# [수험자입력] 1. 사전등록 : 서울 국제 도서 박람회 흠페이지(http://www.ind.or.kr) 참조
|
|
|
|
|
# [정답] 서울 국제 도서 박람회 흠페이지(http://www.ind.or.kr) 참조
|
|
|
|
|
# 수험자 텍스트의 "1. 사전등록" 부분을 제외하고 난 뒤
|
|
|
|
|
# 남은 "서울 국제 도서 박람회 흠페이지(http://www.ind.or.kr) 참조"의 정답 부분과 유사도를 비교
|
|
|
|
|
|
2025-05-19 17:50:18 +09:00
|
|
|
text_list = p.xpath(".//CHAR/text()")
|
|
|
|
|
full_text = ''.join(text_list).replace(" ", "")
|
|
|
|
|
# print("full_text: ", full_text)
|
|
|
|
|
|
|
|
|
|
# 채점하고자 하는 문자열 (search_value)의 첫 문자
|
|
|
|
|
first_char = search_value[0]
|
|
|
|
|
|
|
|
|
|
# 수험자 답안에서 첫 문자 인덱스 위치
|
|
|
|
|
user_answer_first_index = full_text.find(first_char)
|
|
|
|
|
|
|
|
|
|
if user_answer_first_index != -1:
|
|
|
|
|
# 수험자 답안에서 첫 문자 인덱스 위치부터 search_value 길이만큼 잘라서 비교
|
|
|
|
|
trimmed_full_text = full_text[user_answer_first_index:]
|
|
|
|
|
else:
|
|
|
|
|
trimmed_full_text = full_text
|
|
|
|
|
|
|
|
|
|
# 두 문자열의 유사도 계산
|
|
|
|
|
similarity = difflib.SequenceMatcher(None, trimmed_full_text, hyperlink_text).ratio()
|
|
|
|
|
|
|
|
|
|
# 두 문자열의 유사도에 따라 하이퍼링크 확인
|
|
|
|
|
# 유사도가 낮은 경우 오답처리
|
|
|
|
|
if similarity < 0.7:
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, 0, method="equal")
|
|
|
|
|
|
|
|
|
|
# 유사도가 높은 경우
|
|
|
|
|
else:
|
|
|
|
|
inside_field = False
|
|
|
|
|
charshape_list = []
|
|
|
|
|
|
|
|
|
|
for elem in p.iter():
|
|
|
|
|
# 시작 지점 확인
|
2025-06-05 18:00:46 +09:00
|
|
|
# FIELDBEGIN태그와 FIELDEND태그 사이
|
2025-05-19 17:50:18 +09:00
|
|
|
if elem.tag == "FIELDBEGIN":
|
|
|
|
|
inside_field = True
|
|
|
|
|
elif elem.tag == "FIELDEND":
|
|
|
|
|
inside_field = False
|
2025-06-05 18:00:46 +09:00
|
|
|
|
|
|
|
|
# 하이퍼링크 텍스트가 CharShape 속성값이 앞의 텍스트와 다른 경우
|
|
|
|
|
# http://www.ihd.or.kr 주소가 TEXT 부모태그를 가지는 경우
|
|
|
|
|
# [예시]
|
|
|
|
|
# <TEXT CharShape="21">
|
|
|
|
|
# <CHAR>http://www.ihd.or.kr)</CHAR>
|
|
|
|
|
# </TEXT>
|
|
|
|
|
# 해당 부모 TEXT태그의 CharShape속성을 확인
|
2025-05-19 17:50:18 +09:00
|
|
|
elif inside_field and elem.tag == "TEXT":
|
|
|
|
|
charshape = elem.get("CharShape")
|
2025-06-05 18:00:46 +09:00
|
|
|
print('charshape : ', charshape)
|
|
|
|
|
if charshape:
|
|
|
|
|
charshape_list.append(charshape)
|
|
|
|
|
|
|
|
|
|
# 하이퍼링크 텍스트가 CharShape 속성값이 앞의 텍스트와 같은 경우
|
|
|
|
|
# http://www.ihd.or.kr 주소가 TEXT부모태그 없이 CHAR로만 있는경우
|
|
|
|
|
# [예시]
|
|
|
|
|
# <CHAR>http://www.ihd.or.kr)</CHAR>
|
|
|
|
|
# FIELDBEGIN밖의 TEXT태그의 CharShape속성을 확인해야 한다
|
|
|
|
|
elif inside_field and elem.tag == "CHAR":
|
|
|
|
|
parent = elem.getparent()
|
|
|
|
|
|
|
|
|
|
charshape = parent.get("CharShape")
|
|
|
|
|
print('charshape : ', charshape)
|
2025-05-19 17:50:18 +09:00
|
|
|
if charshape:
|
|
|
|
|
charshape_list.append(charshape)
|
2025-06-05 18:00:46 +09:00
|
|
|
|
2025-05-19 17:50:18 +09:00
|
|
|
|
|
|
|
|
# 하이퍼링크에 해당하는 P태그 내 존재하는 charshape ID값 모두를 비교해 해당 속성(ITALIC, BOLD, UNDERLINE) 확인
|
|
|
|
|
# 모든 charshape ID값이 정답과 일치하는 경우에만 점수 부여
|
|
|
|
|
all_attributes_match = True
|
|
|
|
|
if charshape_list:
|
|
|
|
|
for charshape_id in charshape_list:
|
|
|
|
|
charshape = root.xpath(f"//CHARSHAPE[@Id='{charshape_id}']")
|
|
|
|
|
|
|
|
|
|
# 속성 태그가 존재하는지 확인
|
|
|
|
|
font_attribute = charshape[0].find(right_answer)
|
|
|
|
|
if font_attribute is None:
|
|
|
|
|
user_answer = None
|
|
|
|
|
all_attributes_match = False
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
user_answer = font_attribute.tag
|
|
|
|
|
|
|
|
|
|
if all_attributes_match:
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
|
|
|
|
else:
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, 0, method="equal")
|
|
|
|
|
|
2025-06-05 18:00:46 +09:00
|
|
|
elif (category or "") == "LineSpacing":
|
2025-06-27 16:22:18 +09:00
|
|
|
page1_ptags = pages.get('Page_1', [])
|
2025-06-05 18:00:46 +09:00
|
|
|
|
|
|
|
|
# 줄간격이 하나라도 일치하지 않을 경우 오답처리
|
|
|
|
|
linespacing_match = True
|
2025-06-27 16:22:18 +09:00
|
|
|
for p in page1_ptags:
|
2025-06-05 18:00:46 +09:00
|
|
|
parashape_id = p.get('ParaShape')
|
|
|
|
|
xpath = xpath.replace('{parashape_id}', parashape_id)
|
|
|
|
|
linespacing = root.xpath(xpath)
|
|
|
|
|
user_answer = linespacing[0]
|
|
|
|
|
|
2025-07-29 18:00:04 +09:00
|
|
|
# print("🟡줄간격: ", user_answer)
|
2025-06-05 18:00:46 +09:00
|
|
|
if user_answer != right_answer:
|
|
|
|
|
linespacing_match = False
|
|
|
|
|
break
|
2025-05-19 17:50:18 +09:00
|
|
|
|
2025-06-24 16:57:51 +09:00
|
|
|
# 문단 첫 글자 크기에 따라 채점 기준 추가 (050624)
|
|
|
|
|
# 1. 기본 줄간격 160% 일 때 26pt
|
|
|
|
|
# 2. 해당 문제의 정답 줄간격 (180% = 28pt / 200% = 30pt )
|
|
|
|
|
# 두 경우의 글자 크기가 아니라면 오답처리
|
|
|
|
|
firstword = criterion.get('first_word', None)
|
2025-07-29 18:00:04 +09:00
|
|
|
result = root.xpath(f"//CHARSHAPE[@Id=//RECTANGLE//TEXT[CHAR[text()='{firstword}']]/@CharShape]/@Height")
|
2025-06-24 16:57:51 +09:00
|
|
|
firstword_size = result[0] if result else None
|
|
|
|
|
|
|
|
|
|
if (right_answer == '180' and firstword_size not in ['2600', '2800', None]) or (right_answer == '200' and firstword_size not in ['2600', '3000', None]):
|
|
|
|
|
linespacing_match = False
|
|
|
|
|
|
2025-06-05 18:00:46 +09:00
|
|
|
if linespacing_match is True:
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
|
|
|
|
else:
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, 0, method="equal")
|
2025-04-24 18:02:32 +09:00
|
|
|
|
2025-04-25 17:57:04 +09:00
|
|
|
|
|
|
|
|
# 특수문자 갯수 채점
|
2025-05-16 17:58:33 +09:00
|
|
|
elif (category or "") == "SpecialChar":
|
2025-04-25 17:57:04 +09:00
|
|
|
ch1 = criterion.get('char1', None)
|
|
|
|
|
ch2 = criterion.get('char2', None)
|
2025-05-12 18:24:35 +09:00
|
|
|
ch3 = criterion.get('char3', None)
|
2025-04-25 17:57:04 +09:00
|
|
|
xpath = xpath.replace('{char1}', ch1)
|
|
|
|
|
xpath2 = xpath2.replace('{char2}', ch2)
|
2025-05-12 18:24:35 +09:00
|
|
|
xpath3 = xpath3.replace('{char3}', ch3)
|
2025-07-04 17:40:41 +09:00
|
|
|
ch1_str = root.xpath(xpath)
|
|
|
|
|
ch2_str = root.xpath(xpath2)
|
|
|
|
|
ch3_str = root.xpath(xpath3)
|
2025-04-25 17:57:04 +09:00
|
|
|
sum_char = 0
|
|
|
|
|
|
|
|
|
|
# char1 요소에서 특수문자 갯수 세기 (최대 2점)
|
2025-07-04 17:40:41 +09:00
|
|
|
for text in ch1_str or []:
|
|
|
|
|
ch1_count = text.count(ch1)
|
|
|
|
|
sum_char += ch1_count
|
2025-05-12 18:24:35 +09:00
|
|
|
if sum_char >= 2:
|
|
|
|
|
sum_char = 2
|
|
|
|
|
break
|
2025-04-25 17:57:04 +09:00
|
|
|
|
|
|
|
|
# char2 요소에서 특수문자 갯수 세기 (최대 1점)
|
2025-05-13 19:46:19 +09:00
|
|
|
# char1과 char2가 다른 경우 (예: ▶ 행사안내 ◀)
|
2025-07-04 17:40:41 +09:00
|
|
|
if (ch1 != ch2) and ch2_str:
|
|
|
|
|
for text in ch2_str or []:
|
|
|
|
|
ch2_count = text.count(ch2)
|
|
|
|
|
if ch2_count > 1:
|
|
|
|
|
ch2_count = 1
|
|
|
|
|
sum_char += ch2_count
|
2025-05-12 18:24:35 +09:00
|
|
|
|
2025-07-04 17:40:41 +09:00
|
|
|
# char3 요소에서 특수문자 갯수 세기 (최대 1점)
|
|
|
|
|
if ch3_str:
|
|
|
|
|
for text in ch3_str or []:
|
|
|
|
|
ch3_count = text.count(ch3)
|
|
|
|
|
if ch3_count > 1:
|
|
|
|
|
ch3_count = 1
|
|
|
|
|
sum_char += ch3_count
|
2025-04-25 17:57:04 +09:00
|
|
|
|
|
|
|
|
user_answer = sum_char
|
|
|
|
|
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="partial_score")
|
|
|
|
|
|
2025-05-13 19:46:19 +09:00
|
|
|
# 쪽 테두리 (이중 실선, 머리말 포함) 설정
|
2025-05-16 17:58:33 +09:00
|
|
|
elif (category or "") == "PageBorder":
|
2025-05-13 19:46:19 +09:00
|
|
|
user_answer = {
|
|
|
|
|
"header_inside": False,
|
|
|
|
|
"all_double_slim": False
|
|
|
|
|
}
|
2025-04-25 17:57:04 +09:00
|
|
|
|
2025-05-13 19:46:19 +09:00
|
|
|
# 머릿말 포함 객체가 하나라도 있으면 정답
|
|
|
|
|
header_inside_elements = root.xpath(xpath)
|
|
|
|
|
for header_inside in header_inside_elements:
|
|
|
|
|
# print("머릿말포함: ",header_inside)
|
|
|
|
|
if "true" in header_inside:
|
|
|
|
|
user_answer["header_inside"] = True
|
2025-05-16 17:58:33 +09:00
|
|
|
break
|
2025-04-25 17:57:04 +09:00
|
|
|
|
2025-05-13 19:46:19 +09:00
|
|
|
# BORDERFILL요소의 자녀
|
|
|
|
|
# LEFTBORDER, RIGHTBORDER, TOPBORDER, BOTTOMBORDER 요소의 Type속성이
|
|
|
|
|
# 모두 DoubleSlim이면 정답
|
2025-05-16 17:58:33 +09:00
|
|
|
border_tags = ["LEFTBORDER", "RIGHTBORDER", "TOPBORDER", "BOTTOMBORDER"]
|
|
|
|
|
|
|
|
|
|
borderfill_elements = root.xpath(xpath2)
|
|
|
|
|
for borderfill in borderfill_elements:
|
2025-05-13 19:46:19 +09:00
|
|
|
all_double_slim = True
|
2025-05-16 17:58:33 +09:00
|
|
|
|
2025-05-13 19:46:19 +09:00
|
|
|
for tag in border_tags:
|
2025-05-16 17:58:33 +09:00
|
|
|
element = borderfill.find(tag)
|
|
|
|
|
|
|
|
|
|
if (element is None) or (element.get("Type") != "DoubleSlim"):
|
2025-05-13 19:46:19 +09:00
|
|
|
all_double_slim = False
|
|
|
|
|
break
|
|
|
|
|
|
2025-05-16 17:58:33 +09:00
|
|
|
#모든 BORDER 태그의 Type 속성이 'DoubleSlim'인 객체가 있다면 반복문 탈출
|
2025-05-13 19:46:19 +09:00
|
|
|
if all_double_slim:
|
|
|
|
|
user_answer["all_double_slim"] = True
|
2025-05-16 17:58:33 +09:00
|
|
|
break
|
2025-05-13 19:46:19 +09:00
|
|
|
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
2025-06-09 18:01:35 +09:00
|
|
|
|
|
|
|
|
# 다단 확인 [2-3]문항
|
|
|
|
|
elif (category or "") == "TwoColumn":
|
2025-06-27 16:22:18 +09:00
|
|
|
page2_ptags = pages.get('Page_2', [])
|
2025-06-11 15:29:48 +09:00
|
|
|
|
2025-06-27 16:22:18 +09:00
|
|
|
for p in page2_ptags:
|
|
|
|
|
column_count = p.xpath(xpath)
|
|
|
|
|
user_answer = column_count[0] if column_count else '0'
|
2025-06-11 15:29:48 +09:00
|
|
|
|
2025-06-27 16:22:18 +09:00
|
|
|
if user_answer == right_answer:
|
2025-06-11 15:29:48 +09:00
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
|
2025-06-27 16:22:18 +09:00
|
|
|
|
|
|
|
|
# P태그들 중 하나라도 다단이 존재할 경우 정답처리
|
|
|
|
|
if scoring['points'] > 0:
|
2025-06-11 15:29:48 +09:00
|
|
|
break
|
|
|
|
|
|
2025-05-15 18:06:06 +09:00
|
|
|
# 한자
|
2025-05-23 17:18:55 +09:00
|
|
|
elif (category or "") == "Hanja":
|
2025-05-14 18:01:36 +09:00
|
|
|
# 점수 계산
|
|
|
|
|
score = 0
|
|
|
|
|
max_score = points
|
|
|
|
|
|
2025-05-23 17:18:55 +09:00
|
|
|
word_list = criterion.get('word', [])
|
2025-05-14 18:01:36 +09:00
|
|
|
# 부분점수 (최대점수에서 한자 갯수만큼 나눈 몫)
|
|
|
|
|
score_per_pair = max_score // len(word_list)
|
|
|
|
|
|
|
|
|
|
# 한자가 5개 고정일 경우
|
|
|
|
|
# score_per_pair = 2
|
|
|
|
|
|
|
|
|
|
for kor, chn in word_list:
|
|
|
|
|
# XPath 구문 구성 및 실행
|
|
|
|
|
exec_xpath = xpath.replace('{kor}', kor).replace('{chn}', chn)
|
|
|
|
|
matched = root.xpath(exec_xpath)
|
|
|
|
|
|
|
|
|
|
if matched:
|
|
|
|
|
score += score_per_pair
|
|
|
|
|
|
|
|
|
|
# 최대 점수 초과 방지
|
|
|
|
|
user_answer = min(score, max_score)
|
|
|
|
|
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points, method="partial_score")
|
2025-05-15 18:06:06 +09:00
|
|
|
|
2025-05-16 17:58:33 +09:00
|
|
|
elif (category or "") == "ChartType":
|
2025-05-15 18:06:06 +09:00
|
|
|
chart_type_list = {
|
2025-05-20 18:02:04 +09:00
|
|
|
'꺾은선형': "//c:lineChart[c:grouping[@val='standard']]",
|
2025-05-23 17:18:55 +09:00
|
|
|
'묶은가로막대형': "//c:barChart[c:barDir[@val='bar'] and c:grouping[@val='clustered']]",
|
2025-05-26 17:46:45 +09:00
|
|
|
'누적가로막대형': "//c:barChart[c:barDir[@val='bar'] and c:grouping[@val='stacked']]",
|
2025-05-23 17:18:55 +09:00
|
|
|
'묶은세로막대형': "//c:barChart[c:barDir[@val='col'] and c:grouping[@val='clustered']]",
|
2025-05-26 17:46:45 +09:00
|
|
|
'누적세로막대형': "//c:barChart[c:barDir[@val='col'] and c:grouping[@val='stacked']]",
|
2025-05-15 18:06:06 +09:00
|
|
|
'원형': "//c:pieChart",
|
|
|
|
|
'분산형': "//c:scatterChart"
|
|
|
|
|
}
|
|
|
|
|
chart_type = criterion.get('chart_type').replace(" ","")
|
2025-05-23 17:18:55 +09:00
|
|
|
|
2025-05-20 18:02:04 +09:00
|
|
|
# 입력한 chart_type에 해당하는 xpath를 가져옴
|
2025-05-15 18:06:06 +09:00
|
|
|
chart_xpath = chart_type_list[chart_type]
|
|
|
|
|
|
2025-05-20 18:02:04 +09:00
|
|
|
# xpath를 사용하여 차트 요소가 있는지 확인
|
2025-05-15 18:06:06 +09:00
|
|
|
user_answer = bool(chart_tree.xpath(chart_xpath, namespaces=namespaces))
|
|
|
|
|
self.evaluate_answer(scoring, user_answer, right_answer, points)
|
|
|
|
|
|
|
|
|
|
# 문항 채점 결과를 리스트에 입력
|
2025-04-23 17:48:46 +09:00
|
|
|
onePersonResult['score_results'].append(scoring)
|
|
|
|
|
print(f'scoring: {scoring}')
|
2025-05-15 18:06:06 +09:00
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
onePersonResult['partial_scores'].append({
|
|
|
|
|
'section': section_id,
|
|
|
|
|
'score': self.partial_score
|
|
|
|
|
})
|
|
|
|
|
onePersonResult['total_score'] = self.total_score
|
|
|
|
|
return onePersonResult
|
|
|
|
|
|
|
|
|
|
except ET.ParseError as e:
|
|
|
|
|
return {
|
|
|
|
|
'filename': os.path.basename(xml_file),
|
|
|
|
|
'error': f"XML 파싱 오류: {str(e)}",
|
|
|
|
|
'total_score': 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def binary_to_chartxml(self, xml_path):
|
|
|
|
|
tree = ET.parse(xml_path)
|
|
|
|
|
root = tree.getroot()
|
|
|
|
|
|
|
|
|
|
binary_data = root.xpath('//BINDATA[@Id=//BINITEM[@Format="OLE"]/@BinData]/text()')
|
|
|
|
|
if not binary_data:
|
|
|
|
|
return None
|
|
|
|
|
binary_data = binary_data[0].encode('utf-8')
|
|
|
|
|
|
|
|
|
|
# <BINDATA ...> 태그와 그 내부 내용을 삭제합니다.
|
|
|
|
|
encoded_data = re.sub(b'<BINDATA.*?>', b'', binary_data)
|
|
|
|
|
encoded_data = encoded_data.replace(b'</BINDATA>', b'')
|
|
|
|
|
encoded_data = encoded_data.replace(b'\r\n', b'')
|
|
|
|
|
|
|
|
|
|
# base64 디코딩을 수행합니다.
|
|
|
|
|
decoded_data = base64.b64decode(encoded_data+b'==')
|
|
|
|
|
|
|
|
|
|
# 디코딩된 데이터 내용 중 xml 형식만 추출할 때 <c:chartSpace>, </c:chartSpace> 사이의 데이터만 추출.
|
|
|
|
|
start = decoded_data.find(b'<?xml')
|
|
|
|
|
print(start)
|
|
|
|
|
end = decoded_data.find(b'</c:chartSpace>')
|
|
|
|
|
print(end)
|
|
|
|
|
xml_data = decoded_data[start:end+len(b'</c:chartSpace>')]
|
|
|
|
|
|
|
|
|
|
# xml 데이터가 없는 경우 None을 반환합니다.
|
|
|
|
|
if -1 in [start, end]:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# 디코딩된 데이터를 파일로 저장합니다.
|
|
|
|
|
base_filename = os.path.splitext(xml_path)[0]
|
|
|
|
|
new_filename = f'{base_filename}.xml'
|
|
|
|
|
with open(new_filename, 'wb') as file:
|
|
|
|
|
file.write(xml_data)
|
|
|
|
|
|
|
|
|
|
return xml_data
|
|
|
|
|
|
2025-05-16 17:58:33 +09:00
|
|
|
def typo_check(self, correct_answer_file, user_answer_file, chart_xml):
|
2025-06-26 17:30:18 +09:00
|
|
|
|
|
|
|
|
# 문자열 리스트를 필터링
|
|
|
|
|
def clean_text_list(text_list, ignore_words=None):
|
|
|
|
|
result = []
|
|
|
|
|
for text in text_list:
|
|
|
|
|
if ignore_words:
|
|
|
|
|
text = text.replace(ignore_words, '')
|
|
|
|
|
text = text.replace(' ', '') # 공백 제거
|
|
|
|
|
text = re.sub(r'\d+\.\s*|-', '', text) # 숫자. / - 제거
|
|
|
|
|
result.append(text)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
# 1. 텍스트 추출
|
|
|
|
|
# 2. 공백제거, 특정 형식 제거
|
|
|
|
|
# 3. 리스트를 문자열로 변환
|
|
|
|
|
|
2025-05-23 17:18:55 +09:00
|
|
|
user_answer_root = ET.parse(user_answer_file).getroot()
|
|
|
|
|
correct_answer_root = ET.parse(correct_answer_file).getroot()
|
2025-05-16 17:58:33 +09:00
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# xpath로 바이너리 부분추출
|
|
|
|
|
user_input_text = user_answer_root.xpath('//CHAR//text()[not(ancestor::HEADER) and not(ancestor::TABLE)]')
|
|
|
|
|
correct_input_text = correct_answer_root.xpath('//CHAR//text()[not(ancestor::HEADER) and not(ancestor::TABLE)]')
|
2025-06-26 17:30:18 +09:00
|
|
|
|
|
|
|
|
# 테이블 구간 추출
|
|
|
|
|
user_table_text = user_answer_root.xpath('//TABLE//CHAR//text()')
|
2025-04-23 17:48:46 +09:00
|
|
|
correct_table_text = correct_answer_root.xpath('//TABLE//CHAR//text()')
|
2025-05-16 17:58:33 +09:00
|
|
|
|
2025-06-27 16:22:18 +09:00
|
|
|
user_chart_title = ""
|
|
|
|
|
correct_chart_title = self.scoring_criteria["2"]["50"]["searchValue"]
|
|
|
|
|
|
2025-06-26 17:30:18 +09:00
|
|
|
# 차트 XML에서 차트제목 추출
|
2025-05-16 17:58:33 +09:00
|
|
|
if chart_xml is not None:
|
|
|
|
|
chart_xml_tree = ET.fromstring(chart_xml)
|
2025-06-27 16:22:18 +09:00
|
|
|
ns = {'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart',
|
|
|
|
|
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
|
|
|
|
|
xpath_expr = '/c:chartSpace/c:chart/c:title/c:tx/c:rich/a:p/a:r/a:t'
|
|
|
|
|
|
2025-05-16 17:58:33 +09:00
|
|
|
# 차트 제목 추출
|
2025-06-27 16:22:18 +09:00
|
|
|
chart_title = chart_xml_tree.xpath(xpath_expr, namespaces=ns)
|
2025-05-16 17:58:33 +09:00
|
|
|
|
|
|
|
|
# 차트 제목이 존재하는 경우
|
2025-06-27 16:22:18 +09:00
|
|
|
user_chart_title = chart_title[0].text if chart_title else ""
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
try :
|
2025-05-16 17:58:33 +09:00
|
|
|
ignore_word = self.scoring_criteria["2"]["29"]["ignoreWord"]
|
2025-04-23 17:48:46 +09:00
|
|
|
# 특정 단어 제거
|
|
|
|
|
# 오타와 누락의 경우만 판단하면 정상작동하지만
|
|
|
|
|
# 추가 된 단어의 경우를 채점기준에 추가하면 정확하게 채점 되지 않을 수 있음
|
|
|
|
|
# [정답] Hybrid [실제작성]
|
|
|
|
|
user_input_text = [text.replace(ignore_word, '') for text in user_input_text]
|
|
|
|
|
correct_input_text = [text.replace(ignore_word, '') for text in correct_input_text]
|
|
|
|
|
except (KeyError, IndexError, AttributeError):
|
|
|
|
|
ignore_word = None
|
|
|
|
|
|
2025-05-29 16:41:57 +09:00
|
|
|
# print(f"ignore_word: {ignore_word}")
|
2025-06-26 17:30:18 +09:00
|
|
|
|
|
|
|
|
# 문자열 필터링
|
|
|
|
|
correct_input_text = clean_text_list(correct_input_text, ignore_word)
|
|
|
|
|
user_input_text = clean_text_list(user_input_text, ignore_word)
|
|
|
|
|
|
|
|
|
|
correct_table_text = clean_text_list(correct_table_text)
|
|
|
|
|
user_table_text = clean_text_list(user_table_text)
|
|
|
|
|
|
|
|
|
|
correct_chart_title = clean_text_list(correct_chart_title)
|
|
|
|
|
user_chart_title = clean_text_list(user_chart_title)
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 리스트를 하나의 문자열로 변경
|
2025-06-26 17:30:18 +09:00
|
|
|
correct_input_text_str = ''.join(correct_input_text)
|
2025-04-23 17:48:46 +09:00
|
|
|
user_input_text_str = ''.join(user_input_text)
|
|
|
|
|
|
2025-06-26 17:30:18 +09:00
|
|
|
correct_table_text_str = ''.join(correct_table_text)
|
|
|
|
|
user_table_text_str = ''.join(user_table_text)
|
|
|
|
|
|
|
|
|
|
correct_chart_title_str = ''.join(correct_chart_title)
|
|
|
|
|
user_chart_title_str = ''.join(user_chart_title)
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
print("user_input_text as string:")
|
|
|
|
|
print(user_input_text_str)
|
2025-06-26 17:30:18 +09:00
|
|
|
print("\n")
|
|
|
|
|
print("correct_input_text_answer as string:")
|
|
|
|
|
print(correct_input_text_str)
|
2025-05-29 16:41:57 +09:00
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 문자열의 차이를 비교
|
2025-06-26 17:30:18 +09:00
|
|
|
text_diff = difflib.ndiff(correct_input_text_str, user_input_text_str)
|
|
|
|
|
table_text_diff = difflib.ndiff(correct_table_text_str, user_table_text_str)
|
|
|
|
|
chart_title_diff = difflib.ndiff(correct_chart_title_str, user_chart_title_str)
|
|
|
|
|
|
|
|
|
|
# text_diff = difflib.ndiff(correct_input_text, user_input_text)
|
|
|
|
|
# table_text_diff = difflib.ndiff(correct_table_text, user_table_text)
|
|
|
|
|
# chart_title_diff = difflib.ndiff(correct_chart_title, user_chart_title)
|
|
|
|
|
# diff_list = list(diff)
|
|
|
|
|
text_list = list(text_diff)
|
|
|
|
|
table_list = list(table_text_diff)
|
|
|
|
|
chart_list = list(chart_title_diff)
|
|
|
|
|
|
|
|
|
|
diff_list = text_list + table_list + chart_list
|
|
|
|
|
# diff_list = text_list + table_list
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
# 차이점을 정리하여 result_diff에 저장
|
|
|
|
|
result_diff = []
|
|
|
|
|
|
|
|
|
|
# 누락 된 단어만 따로 리스트로 저장
|
|
|
|
|
missing_list = []
|
|
|
|
|
|
|
|
|
|
# 오타와 누락된 단어 리스트 저장
|
|
|
|
|
error_missing_list = []
|
|
|
|
|
|
|
|
|
|
skip_next = False
|
|
|
|
|
|
|
|
|
|
for i, line in enumerate(diff_list):
|
|
|
|
|
if skip_next:
|
|
|
|
|
skip_next = False
|
|
|
|
|
continue
|
|
|
|
|
# diff_list의 line 시작이 '-'이면서 다음 line이 '+'이면 두 line을 붙여서 맞춤법이 틀린 단어로 판단
|
|
|
|
|
if line.startswith('- '):
|
|
|
|
|
# 오타
|
|
|
|
|
if i + 1 < len(diff_list) and diff_list[i + 1].startswith('+ '):
|
|
|
|
|
line = line.replace('- ', '-')
|
|
|
|
|
next = diff_list[i + 1].replace('+ ', '')
|
|
|
|
|
result_diff.append(line+'=>'+next)
|
|
|
|
|
error_missing_list.append(line+'=>'+next)
|
|
|
|
|
skip_next = True
|
|
|
|
|
# 누락
|
|
|
|
|
else:
|
|
|
|
|
line = line.replace('- ', '-')
|
|
|
|
|
result_diff.append(line)
|
|
|
|
|
missing_list.append(line)
|
|
|
|
|
error_missing_list.append(line)
|
|
|
|
|
# 없어도 되는 글자가 있는 경우 (추가)
|
|
|
|
|
elif line.startswith('+ '):
|
|
|
|
|
line = line.replace('+ ', '+')
|
|
|
|
|
result_diff.append(line)
|
|
|
|
|
|
|
|
|
|
# result_diff 출력
|
|
|
|
|
# print("\nResult Differences:")
|
|
|
|
|
# for diff in result_diff:
|
|
|
|
|
# print(diff)
|
|
|
|
|
|
|
|
|
|
# result_diff 배열의 길이를 맨 앞에 저장
|
|
|
|
|
|
|
|
|
|
# 모든 차이를 계산해 점수 차감
|
|
|
|
|
# temp = 40 - min(len(result_diff)*2, 40)
|
|
|
|
|
|
|
|
|
|
# 누락된 텍스트만 계산해 점수 차감
|
|
|
|
|
# temp = 40 - min(len(missing_list)*2, 40)
|
|
|
|
|
|
|
|
|
|
# 2503회 기준 오타 1개당 [2점]->[1점] 차감
|
|
|
|
|
temp = 40 - min(len(error_missing_list)*1, 40)
|
|
|
|
|
|
|
|
|
|
self.set_typo_score(temp)
|
|
|
|
|
|
|
|
|
|
result_diff.insert(0, temp)
|
|
|
|
|
return result_diff
|
|
|
|
|
|
|
|
|
|
# XML 파일 채점
|
|
|
|
|
def score_directory(self, xml_directory, correct_answer_file):
|
|
|
|
|
# xml 파일 불러오기
|
|
|
|
|
xml_files = Path(xml_directory).glob('*.hml')
|
|
|
|
|
|
|
|
|
|
# 채점결과 저장할 리스트
|
|
|
|
|
score_results = []
|
|
|
|
|
|
|
|
|
|
for user_answer_file in xml_files:
|
|
|
|
|
score_result = {}
|
|
|
|
|
chart_xml = self.binary_to_chartxml(user_answer_file)
|
2025-05-16 17:58:33 +09:00
|
|
|
score_result['typo'] = self.typo_check(correct_answer_file, user_answer_file, chart_xml)
|
2025-04-23 17:48:46 +09:00
|
|
|
score_result['score'] = self._score_xml_file(user_answer_file, chart_xml)
|
|
|
|
|
# score_result['score']['score_results'][2]['points'] = score_result['typo'][0]
|
|
|
|
|
score_results.append(score_result)
|
|
|
|
|
return score_results
|
|
|
|
|
|
|
|
|
|
def parse_filename(self, filename):
|
|
|
|
|
if isinstance(filename, dict):
|
|
|
|
|
filename = filename.get('파일명', '')
|
|
|
|
|
match = re.match(r'.*-(\d+)-(.+)\.hml', filename)
|
|
|
|
|
if match:
|
|
|
|
|
number = match.group(1)
|
|
|
|
|
name = match.group(2)
|
|
|
|
|
return number, name
|
|
|
|
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
def export_to_excel(self, results, output_path=None):
|
|
|
|
|
if output_path is None:
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") #연월일_시분초
|
|
|
|
|
# timestamp = datetime.now().strftime("%Y%m%d") #연월일
|
|
|
|
|
output_path = f"scoring_results_{timestamp}.xlsx"
|
|
|
|
|
|
|
|
|
|
summary_data = []
|
|
|
|
|
detail_data = []
|
|
|
|
|
typo_data = []
|
|
|
|
|
|
|
|
|
|
for temp in results:
|
|
|
|
|
# 요약 정보
|
|
|
|
|
result = temp['score']
|
|
|
|
|
summary_row = {
|
|
|
|
|
'파일명': result['filename'],
|
|
|
|
|
'총점': result.get('total_score', 0)
|
|
|
|
|
}
|
|
|
|
|
if 'error' in result:
|
|
|
|
|
summary_row['오류'] = result['error']
|
|
|
|
|
|
|
|
|
|
summary_data.append(summary_row)
|
|
|
|
|
|
|
|
|
|
# 상세 정보
|
|
|
|
|
if 'score_results' in result:
|
|
|
|
|
filename = {'파일명': result['filename']}
|
|
|
|
|
number, name = self.parse_filename(filename)
|
|
|
|
|
if (number or name) is None:
|
|
|
|
|
detail_row = {'채점항목': result['filename'] }
|
|
|
|
|
else:
|
|
|
|
|
detail_row = {'채점항목':f"{number}-{name}"}
|
|
|
|
|
|
|
|
|
|
section_num = None
|
|
|
|
|
row_index = []
|
|
|
|
|
for i, score_result in enumerate(result['score_results']):
|
|
|
|
|
current_section = score_result['section']
|
|
|
|
|
|
|
|
|
|
if section_num is None:
|
|
|
|
|
section_num = current_section
|
|
|
|
|
|
|
|
|
|
# 다음 섹션(문제0 => 문제1)로 넘어갔을 경우 or 마지막 문제일 경우
|
|
|
|
|
if current_section != section_num:
|
|
|
|
|
# 이전 섹션의 부분합을 출력
|
|
|
|
|
detail_row[f'문제{section_num}'] = result['partial_scores'][int(section_num)]['score']
|
|
|
|
|
row_index.append(f'문제{section_num}')
|
|
|
|
|
section_num = current_section
|
|
|
|
|
|
|
|
|
|
detail_row[f'{i+1}'] = score_result['points']
|
|
|
|
|
row_index.append(score_result['id'])
|
|
|
|
|
|
|
|
|
|
# 마지막 섹션(문제2)부분합 점수를 출력
|
|
|
|
|
if i == len(result['score_results']) - 1:
|
|
|
|
|
detail_row[f'문제{current_section}'] = result['partial_scores'][int(current_section)]['score']
|
|
|
|
|
row_index.append(f'문제{current_section}')
|
|
|
|
|
|
|
|
|
|
detail_row['총점'] = result.get('total_score', 0)
|
|
|
|
|
row_index.append('총점')
|
|
|
|
|
detail_data.append(detail_row)
|
|
|
|
|
|
|
|
|
|
summary_df = pd.DataFrame(summary_data)
|
|
|
|
|
detail_df = pd.DataFrame(detail_data).transpose()
|
|
|
|
|
detail_df.columns = detail_df.iloc[0]
|
|
|
|
|
detail_df = detail_df[1:]
|
|
|
|
|
|
|
|
|
|
detail_df.index = row_index
|
|
|
|
|
# detail_df = pd.DataFrame(detail_data)
|
|
|
|
|
|
2025-05-29 16:41:57 +09:00
|
|
|
for one_result in results:
|
|
|
|
|
total_typo_err_score = one_result['typo'][0]
|
|
|
|
|
typo_err_list = one_result['typo'][1:]
|
|
|
|
|
|
|
|
|
|
typo_row = {
|
|
|
|
|
'파일명': one_result['score']['filename'],
|
|
|
|
|
'오타점수': total_typo_err_score,
|
|
|
|
|
}
|
|
|
|
|
typo_row.update({f'오타{i+1}': typo_err for i, typo_err in enumerate(typo_err_list)})
|
|
|
|
|
|
|
|
|
|
typo_data.append(typo_row)
|
2025-05-29 17:33:36 +09:00
|
|
|
|
|
|
|
|
typo_df = pd.DataFrame(typo_data)
|
|
|
|
|
typo_df = typo_df.transpose()
|
2025-05-29 16:41:57 +09:00
|
|
|
# transpose 후 행 -> 열 변환했을 때의 인덱스 제거 (기본 인덱스 제거)
|
|
|
|
|
typo_df.reset_index(drop=True, inplace=True)
|
|
|
|
|
|
|
|
|
|
# transpose 했으므로 첫 행을 컬럼명으로 지정
|
|
|
|
|
typo_df.columns = typo_df.iloc[0] # 첫 행을 컬럼명으로 지정
|
|
|
|
|
typo_df = typo_df.drop(typo_df.index[0]) # 첫 행 제거
|
|
|
|
|
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
# ExcelWriter 객체 생성
|
|
|
|
|
with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
|
|
|
|
|
detail_df.to_excel(writer, sheet_name='채점상세내역', index=True)
|
|
|
|
|
typo_df.to_excel(writer, sheet_name='오타내역', index=False)
|
2025-05-29 16:41:57 +09:00
|
|
|
summary_df.to_excel(writer, sheet_name='채점결과요약', index=False)
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
# 열 너비 자동 조정
|
|
|
|
|
# for sheet_name in writer.sheets:
|
|
|
|
|
# worksheet = writer.sheets[sheet_name]
|
|
|
|
|
# for column_cells in worksheet.columns:
|
|
|
|
|
# max_length = 0
|
|
|
|
|
# column = column_cells[0].column_letter # 열의 문자
|
|
|
|
|
# for cell in column_cells:
|
|
|
|
|
# try:
|
|
|
|
|
# if cell.value:
|
|
|
|
|
# max_length = max(max_length, len(str(cell.value)))
|
|
|
|
|
# except:
|
|
|
|
|
# pass
|
|
|
|
|
# adjusted_width = (max_length + 2)
|
|
|
|
|
# worksheet.column_dimensions[column].width = adjusted_width
|
|
|
|
|
|
|
|
|
|
return output_path
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
|
|
|
|
# 시험회차 및 유형
|
2025-08-29 18:22:31 +09:00
|
|
|
# exam_round = '2507'
|
|
|
|
|
exam_round = '2508'
|
2025-05-23 17:18:55 +09:00
|
|
|
|
|
|
|
|
# 채점하고자 하는 유형은 주석 해제
|
2025-04-23 17:48:46 +09:00
|
|
|
exam_types = [
|
2025-07-09 17:10:59 +09:00
|
|
|
# 'A',
|
2025-07-29 18:00:04 +09:00
|
|
|
'B',
|
|
|
|
|
# 'C',
|
2025-07-09 17:10:59 +09:00
|
|
|
# 'D',
|
2025-04-23 17:48:46 +09:00
|
|
|
]
|
2025-05-23 17:18:55 +09:00
|
|
|
|
2025-07-01 17:49:13 +09:00
|
|
|
test_mode = False
|
|
|
|
|
# test_mode = True #/TEST 폴더 채점시
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
output_excel_paths = []
|
|
|
|
|
for exam_type in exam_types:
|
2025-04-30 17:54:55 +09:00
|
|
|
# JSON 채점기준표 파일 (예시:DIW_2503A.json)
|
2025-05-23 17:18:55 +09:00
|
|
|
scoring_criteria_path = f'./DIW_{exam_round}{exam_type}.json'
|
2025-04-23 17:48:46 +09:00
|
|
|
|
2025-05-29 16:41:57 +09:00
|
|
|
# xml(hml)파일 디렉토리 경로 (예시:./output/2503/A/DIW)
|
2025-04-30 17:54:55 +09:00
|
|
|
xml_directory = f'./output/{exam_round}/{exam_type}/{"TEST" if test_mode else "DIW"}'
|
|
|
|
|
# 오탈자 체크를 위한 정답 파일 경로 (예시:./output/A/DIW/DIW_2503A.hml)
|
|
|
|
|
# correct_answer_file = f'./output/{exam_type}/DIW/DIW_{exam_round}{exam_type}.hml'
|
|
|
|
|
correct_answer_file = f'./output/{exam_round}/{exam_type}/DIW/DIW_{exam_round}{exam_type}.hml'
|
2025-04-23 17:48:46 +09:00
|
|
|
|
2025-04-30 17:54:55 +09:00
|
|
|
# 엑셀 파일명 (비어있으면 자동생성) (예시:241001_DIW_2503A_채점결과.xlsx)
|
2025-04-23 17:48:46 +09:00
|
|
|
timestamp = datetime.now().strftime("%y%m%d")
|
2025-05-29 17:33:36 +09:00
|
|
|
output_path = f'{timestamp}_DIW_{exam_round}{exam_type}_{"TEST" if test_mode else "채점결과"}.xlsx'
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
# 채점 클래스 초기화
|
|
|
|
|
scorer = XMLScorer(scoring_criteria_path)
|
|
|
|
|
|
|
|
|
|
# 폴더 내 모든 xml 파일 채점
|
|
|
|
|
results = scorer.score_directory(xml_directory, correct_answer_file)
|
2025-06-24 16:57:51 +09:00
|
|
|
if not results:
|
|
|
|
|
print(f"❌ 채점 결과가 없습니다. {xml_directory} 폴더에 답안파일이 존재하는지 확인하세요.")
|
|
|
|
|
continue
|
2025-04-23 17:48:46 +09:00
|
|
|
# 채점 결과 엑셀로 저장
|
|
|
|
|
output_excel_paths.append(scorer.export_to_excel(results, output_path))
|
|
|
|
|
|
2025-06-24 16:57:51 +09:00
|
|
|
if output_excel_paths:
|
|
|
|
|
print(f"채점 결과 엑셀 파일: {output_excel_paths}")
|
2025-04-23 17:48:46 +09:00
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
main()
|