[1-30]줄간격/[2-3]다단 페이지 구분으로 채점

This commit is contained in:
2025-06-27 16:22:18 +09:00
parent eb1ed8b105
commit db5ae5b459
4 changed files with 55 additions and 142 deletions

BIN
250627_DIW_2505A_TEST.xlsx Normal file

Binary file not shown.

View File

@@ -342,7 +342,7 @@
"desc": "섹션이 1개 이상이면 점수부여" "desc": "섹션이 1개 이상이면 점수부여"
}, },
"3": { "3": {
"path": "//COLDEF", "path": "TEXT/COLDEF/@Count",
"value": "2", "value": "2",
"points": 3, "points": 3,
"category": "TwoColumn", "category": "TwoColumn",

View File

@@ -127,6 +127,33 @@ class XMLScorer:
# 하나의 XML 파일 채점 # 하나의 XML 파일 채점
def _score_xml_file(self, xml_file, chart_xml): def _score_xml_file(self, xml_file, chart_xml):
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
def extract_char_text_from_p(p_element): def extract_char_text_from_p(p_element):
""" """
주어진 <P> 요소에서 모든 자손 <CHAR>의 텍스트를 추출해 문자열 리스트로 반환합니다. 주어진 <P> 요소에서 모든 자손 <CHAR>의 텍스트를 추출해 문자열 리스트로 반환합니다.
@@ -143,6 +170,10 @@ class XMLScorer:
tree = ET.parse(xml_file) tree = ET.parse(xml_file)
root = tree.getroot() root = tree.getroot()
# XML문서 페이지 파싱 전처리
pages = parse_pages_by_bookmark(root)
print("🚩Pages : ", pages)
# 네임스페이스 정의 # 네임스페이스 정의
namespaces = { namespaces = {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
@@ -163,7 +194,7 @@ class XMLScorer:
'total_score': 0, 'total_score': 0,
'partial_scores': [] 'partial_scores': []
} }
print(f"File name: {onePersonResult['filename']}") print(f"🔜File name: {onePersonResult['filename']}")
self.total_score = 0 self.total_score = 0
for section_id, section in self.scoring_criteria.items(): for section_id, section in self.scoring_criteria.items():
@@ -364,7 +395,6 @@ class XMLScorer:
right_answer = right_answer.replace(" ", "") right_answer = right_answer.replace(" ", "")
self.evaluate_answer(scoring, user_answer, right_answer, points) self.evaluate_answer(scoring, user_answer, right_answer, points)
if scoring['points'] > 0: if scoring['points'] > 0:
break break
@@ -385,8 +415,6 @@ class XMLScorer:
# 사용자 입력값이 mm단위인 경우 # 사용자 입력값이 mm단위인 경우
elif (category or "") == "mmSize": elif (category or "") == "mmSize":
items = root.xpath(xpath) items = root.xpath(xpath)
# 오차범위 설정 # 오차범위 설정
# 한글 프로그램 내부에서 드물게 0mm이지만 1pt로 저장되는 경우가 있음 # 한글 프로그램 내부에서 드물게 0mm이지만 1pt로 저장되는 경우가 있음
# #
@@ -661,23 +689,11 @@ class XMLScorer:
self.evaluate_answer(scoring, user_answer, right_answer, 0, method="equal") self.evaluate_answer(scoring, user_answer, right_answer, 0, method="equal")
elif (category or "") == "LineSpacing": elif (category or "") == "LineSpacing":
# //SECTION[1](1페이지의) 모든 P요소 page1_ptags = pages.get('Page_1', [])
all_p_tags = root.xpath("//SECTION[1]/P")
# 구역이 나뉘어 있지 않은 답안의 처리
# 수험자가 구역 나눔을 적용하지 않고 2페이지 답안을 처리하기 위해서
# [Control+Enter]를 이용해 쪽 나눔을 적용하는 부분을 검색
# P태그의 PageBreak속성값이 'true'가 나오기 전까지의 P태그만 확인
p_tags_before_pagebreak = []
for p in all_p_tags:
if p.get('PageBreak') == 'true':
break
else:
p_tags_before_pagebreak.append(p)
# 줄간격이 하나라도 일치하지 않을 경우 오답처리 # 줄간격이 하나라도 일치하지 않을 경우 오답처리
linespacing_match = True linespacing_match = True
for p in p_tags_before_pagebreak: for p in page1_ptags:
parashape_id = p.get('ParaShape') parashape_id = p.get('ParaShape')
xpath = xpath.replace('{parashape_id}', parashape_id) xpath = xpath.replace('{parashape_id}', parashape_id)
linespacing = root.xpath(xpath) linespacing = root.xpath(xpath)
@@ -784,126 +800,19 @@ class XMLScorer:
# 다단 확인 [2-3]문항 # 다단 확인 [2-3]문항
elif (category or "") == "TwoColumn": elif (category or "") == "TwoColumn":
has_section2 = root.xpath('//SECTION[2]') page2_ptags = pages.get('Page_2', [])
# 구역 나눔이 적용 되어 있지 않은 경우 for p in page2_ptags:
# (= SECTION[2]가 없을 경우) column_count = p.xpath(xpath)
if not has_section2: user_answer = column_count[0] if column_count else '0'
# 모든 <P> 요소 가져오기 if user_answer == right_answer:
p_elements = root.xpath('//SECTION/P')
# PageBreak='true' 속성을 가진 P태그 인덱스
# [=쪽나눔 이후 페이지의 첫 문단들]
pagebreak_index_list = []
for i, p in enumerate(p_elements):
xml_index = i + 1
if p.get("PageBreak") == "true":
pagebreak_index_list.append(xml_index)
# 페이지 별 시작 문단~끝 문단 구간 저장
page_ranges = []
start = 1 # XML은 1-based index
# pagebreak_index_list에 따라 구간 나누기
for index in pagebreak_index_list:
end = index - 1
page_ranges.append((start, end))
start = index
# 마지막 페이지 구간 추가
page_ranges.append((start, len(p_elements))) # 끝까지
# 출력 확인
# for i, (start, end) in enumerate(page_ranges, 1):
# print(f"📄 Page {i}: {start} ~ {end}")
# 단수 구간 결과를 저장할 리스트
column_sections = []
current_count = None
start_index = None
# 모든 P태그를 순회하며 단 나눔이 1단인 구간과 2단인 구간을 저장
for i, p in enumerate(p_elements):
xml_index = i + 1 # XML 기준 1-based index
coldef = p.xpath('.//COLDEF')
if coldef:
# 다단 수(2단)
column_count = coldef[0].get('Count')
# 첫 번째 Count 발견 시 시작점 설정
if current_count is None:
current_count = column_count
start_index = i
# Count 값이 변경되었을 때 이전 구간을 저장
elif column_count != current_count:
column_sections.append((start_index, i - 1, current_count))
# 새 구간 시작
current_count = column_count
start_index = i
# 마지막 구간 저장
if current_count is not None and start_index is not None:
column_sections.append((start_index, len(p_elements) - 1, current_count))
# 결과 출력
# for start, end, count in column_sections:
# xml_start = start + 1 # XML 기준 1-based index
# xml_end = end + 1
# print(f"📄 {count}단 구간: P[{xml_start}] ~ P[{xml_end}]")
# 2페이지 구간 가져오기 (인덱스는 0-based지만 값은 1-based)
if len(page_ranges) > 1:
second_page_start, second_page_end = page_ranges[1]
# 2페이지가 없을 경우 1페이지(문서 전체) 내용으로 대체
# 문서 전체에서 2단 문단이 있을 경우는 정답
else:
second_page_start, second_page_end = page_ranges[0]
# 2페이지가 없을 경우 오답 처리
# else:
# user_answer = None
# 2단 포함 여부 확인 변수
has_two_column_in_page2 = False
# 2단 구간이 2페이지 범위와 겹치는지 확인
# col_start : 다단 시작 P태그 인덱스
# col_end : 다단 끝 P태그 인덱스
# col_count : 다단 수
for col_start, col_end, col_count in column_sections:
two_col_start = col_start + 1 # 1-based
two_col_end = col_end + 1
if col_count == '2':
# 구간이 겹치는지 확인
if two_col_end >= second_page_start and two_col_start <= second_page_end:
has_two_column_in_page2 = True
user_answer = col_count
break
# print("✅ 2페이지에 2단 있음" if has_two_column_in_page2 else "❌ 2페이지에 2단 없음")
if has_two_column_in_page2:
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal") self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
# SECTION[2]가 존재하는 경우 # P태그들 중 하나라도 다단이 존재 경우 정답처리
else: # has_section2 if scoring['points'] > 0:
coldef_in_section2 = has_section2[0].xpath('//COLDEF')
has_correct_column_count = False
for coldef in coldef_in_section2:
column_count = coldef.get('Count')
user_answer = column_count
if user_answer == right_answer:
has_correct_column_count = True
break break
if has_correct_column_count:
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
# 한자 # 한자
elif (category or "") == "Hanja": elif (category or "") == "Hanja":
# 점수 계산 # 점수 계산
@@ -1031,17 +940,21 @@ class XMLScorer:
user_table_text = user_answer_root.xpath('//TABLE//CHAR//text()') user_table_text = user_answer_root.xpath('//TABLE//CHAR//text()')
correct_table_text = correct_answer_root.xpath('//TABLE//CHAR//text()') correct_table_text = correct_answer_root.xpath('//TABLE//CHAR//text()')
user_chart_title = ""
correct_chart_title = self.scoring_criteria["2"]["50"]["searchValue"]
# 차트 XML에서 차트제목 추출 # 차트 XML에서 차트제목 추출
if chart_xml is not None: if chart_xml is not None:
chart_xml_tree = ET.fromstring(chart_xml) chart_xml_tree = ET.fromstring(chart_xml)
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'
# 차트 제목 추출 # 차트 제목 추출
chart_title = chart_xml_tree.xpath('/c:chartSpace/c:chart/c:title/c:tx/c:rich/a:p/a:r/a:t', namespaces={'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart', 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}) chart_title = chart_xml_tree.xpath(xpath_expr, namespaces=ns)
# 차트 제목이 존재하는 경우 # 차트 제목이 존재하는 경우
if chart_title: user_chart_title = chart_title[0].text if chart_title else ""
user_chart_title = chart_title[0].text
correct_chart_title = self.scoring_criteria["2"]["50"]["searchValue"]
try : try :
ignore_word = self.scoring_criteria["2"]["29"]["ignoreWord"] ignore_word = self.scoring_criteria["2"]["29"]["ignoreWord"]
@@ -1296,8 +1209,8 @@ class XMLScorer:
def main(): def main():
# 시험회차 및 유형 # 시험회차 및 유형
# exam_round = '2505' exam_round = '2505'
exam_round = '2506_3' # exam_round = '2506_3'
# 채점하고자 하는 유형은 주석 해제 # 채점하고자 하는 유형은 주석 해제
exam_types = [ exam_types = [

View File

@@ -1 +1 @@
[{"kind":2,"language":"xpath","value":"//a:t[text()='클라우드 보안투자']/ancestor::a:r//a:ea/@typeface"},{"kind":2,"language":"xpath","value":"boolean(//FONTFACE[@Lang='Hangul']/FONT[@Id=//CHARSHAPE/FONTID/@Hangul]/@Name='바탕' and //CHARSHAPE/@Height='1000' and //PARASHAPE/PARAMARGIN/@LineSpacing='160' and //PARASHAPE/@Align='Justify')"},{"kind":2,"language":"xpath","value":"//FONTFACE[@Lang='Hangul']/FONT[@Id=//CHARSHAPE/FONTID/@Hangul]/@Name='바탕'"},{"kind":2,"language":"xpath","value":"//FONTFACE[@Lang='Hangul']/FONT[@Id=//CHARSHAPE/FONTID/@Hangul]/@Name='바탕' and //CHARSHAPE/@Height='1000' and //PARASHAPE/PARAMARGIN/@LineSpacing='160' and //PARASHAPE/@Align='Justify')"},{"kind":2,"language":"xpath","value":"/HWPML/BODY/SECTION/P[19]"},{"kind":2,"language":"xpath","value":"//SECTION"},{"kind":2,"language":"xpath","value":"//P"},{"kind":2,"language":"xpath","value":"//P[.//FIELDBEGIN[@Type='Hyperlink'] and .//CHAR[contains(., 'http')]]"},{"kind":2,"language":"xpath","value":"//PICTURE[./IMAGE[@BinItem=//BINITEM[@Format='JPG']/@BinData]]/SHAPEOBJECT/POSITION[not(@TreatAsChar='true')]/@HorzOffset"},{"kind":2,"language":"xpath","value":"//CHARSHAPE[@Id=//TEXT[CHAR[text()='지']]/@CharShape]/@Height"},{"kind":2,"language":"xpath","value":"//TABLE//CHAR//text()"}] [{"kind":2,"language":"xpath","value":"//a:t[text()='클라우드 보안투자']/ancestor::a:r//a:ea/@typeface"},{"kind":2,"language":"xpath","value":"boolean(//FONTFACE[@Lang='Hangul']/FONT[@Id=//CHARSHAPE/FONTID/@Hangul]/@Name='바탕' and //CHARSHAPE/@Height='1000' and //PARASHAPE/PARAMARGIN/@LineSpacing='160' and //PARASHAPE/@Align='Justify')"},{"kind":2,"language":"xpath","value":"//FONTFACE[@Lang='Hangul']/FONT[@Id=//CHARSHAPE/FONTID/@Hangul]/@Name='바탕'"},{"kind":2,"language":"xpath","value":"//FONTFACE[@Lang='Hangul']/FONT[@Id=//CHARSHAPE/FONTID/@Hangul]/@Name='바탕' and //CHARSHAPE/@Height='1000' and //PARASHAPE/PARAMARGIN/@LineSpacing='160' and //PARASHAPE/@Align='Justify')"},{"kind":2,"language":"xpath","value":"/HWPML/BODY/SECTION/P[19]"},{"kind":2,"language":"xpath","value":"//SECTION"},{"kind":2,"language":"xpath","value":"//P"},{"kind":2,"language":"xpath","value":"//P[.//FIELDBEGIN[@Type='Hyperlink'] and .//CHAR[contains(., 'http')]]"},{"kind":2,"language":"xpath","value":"//PICTURE[./IMAGE[@BinItem=//BINITEM[@Format='JPG']/@BinData]]/SHAPEOBJECT/POSITION[not(@TreatAsChar='true')]/@HorzOffset"},{"kind":2,"language":"xpath","value":"//P//COLDEF/@Count"},{"kind":2,"language":"xpath","value":"//P[.//BOOKMARK/@Name=\"Page_2_start\"]"}]