글상자 채점 방식 수정 [2-10] 까지 진행중

This commit is contained in:
2025-09-09 17:58:09 +09:00
parent 4c5854e8be
commit d4781a350d
4 changed files with 133 additions and 83 deletions

BIN
250909_DIW_2508C_TEST.xlsx Normal file

Binary file not shown.

View File

@@ -351,89 +351,79 @@
"item": "② 다단 2단" "item": "② 다단 2단"
}, },
"4": { "4": {
"path": "//RECTANGLE//CHAR[text()='{searchValue}']/ancestor::RECTANGLE/SHAPEOBJECT/SIZE/@Width", "path": "//RECTANGLE/SHAPEOBJECT/SIZE/@Width",
"searchValue": "구강건강관리",
"value": "60", "value": "60",
"points": 2, "points": 2,
"category": "mmSize", "category": "Rectangle.mmSize",
"item": "문구 (구강건강관리)/① 크기-너비 (60 mm)" "item": "문구 (구강건강관리)/① 크기-너비 (60 mm)"
}, },
"5": { "5": {
"path": "//RECTANGLE//CHAR[text()='{searchValue}']/ancestor::RECTANGLE/SHAPEOBJECT/SIZE/@Height", "path": "//RECTANGLE/SHAPEOBJECT/SIZE/@Height",
"searchValue": "구강건강관리",
"value": "12", "value": "12",
"points": 2, "points": 2,
"category": "mmSize", "category": "Rectangle.mmSize",
"item": "문구 (구강건강관리)/② 크기-높이 (12 mm)" "item": "문구 (구강건강관리)/② 크기-높이 (12 mm)"
}, },
"6": { "6": {
"path": "//RECTANGLE[.//CHAR[text()='{searchValue}']]//LINESHAPE", "path": "//RECTANGLE//LINESHAPE",
"searchValue": "구강건강관리",
"value": { "value": {
"Style": "DoubleSlim", "Style": "DoubleSlim",
"Width": "283" "Width": "283"
}, },
"points": 2, "points": 2,
"category": "LineShape", "category": "Rectangle.LineShape",
"item": "문구 (구강건강관리)/③ 테두리 : 이중 실선(1.00mm)", "item": "문구 (구강건강관리)/③ 테두리 : 이중 실선(1.00mm)",
"desc": "1mm = 283pt value['Width']에 pt값 입력" "desc": "1mm = 283pt value['Width']에 pt값 입력"
}, },
"7": { "7": {
"path": "//RECTANGLE[.//CHAR[text()='{searchValue}']]/@Ratio", "path": "//RECTANGLE/@Ratio",
"searchValue": "구강건강관리",
"value": "20", "value": "20",
"points": 2, "points": 2,
"category": "OneAnswer", "category": "Rectangle.OneAnswer",
"item": "문구 (구강건강관리)/④ 글상자 모서리 (반원)", "item": "문구 (구강건강관리)/④ 글상자 모서리 (반원)",
"desc": "모서리 비율 반원:50 / 둥근모양:20" "desc": "모서리 비율 반원:50 / 둥근모양:20"
}, },
"8": { "8": {
"path": "//RECTANGLE[.//CHAR[text()='{searchValue}']]//WINDOWBRUSH/@FaceColor", "path": "//RECTANGLE//WINDOWBRUSH/@FaceColor",
"searchValue": "구강건강관리",
"value": "187,140,209", "value": "187,140,209",
"points": 2, "points": 2,
"category": "Color", "category": "Rectangle.Color",
"item": "문구 (구강건강관리)/⑤ 채우기 : 색상(RGB:187,140,209)" "item": "문구 (구강건강관리)/⑤ 채우기 : 색상(RGB:187,140,209)"
}, },
"9": { "9": {
"path": "//RECTANGLE[.//CHAR[text()='{searchValue}']]/SHAPEOBJECT/POSITION/@TreatAsChar", "path": "//RECTANGLE/SHAPEOBJECT/POSITION/@TreatAsChar",
"searchValue": "구강건강관리",
"value": "true", "value": "true",
"points": 1, "points": 1,
"category": "OneAnswer", "category": "Rectangle.OneAnswer",
"item": "문구 (구강건강관리)/⑥ 글상자 위치 (글자처럼 취급)" "item": "문구 (구강건강관리)/⑥ 글상자 위치 (글자처럼 취급)"
}, },
"10": { "10": {
"path": "//PARASHAPE[@Id=//RECTANGLE//CHAR[text()='{searchValue}']/ancestor::P[last()]/@ParaShape]/@Align", "path": "//PARASHAPE[@Id='{rectangle_parashape_id}']/@Align",
"searchValue": "구강건강관리",
"value": "Center", "value": "Center",
"points": 1, "points": 1,
"category": "OneAnswer", "category": "Rectangle.TextBoxAlign",
"item": "문구 (구강건강관리)/⑦ 글상자 정렬 (가운데 정렬)" "item": "문구 (구강건강관리)/⑦ 글상자 정렬 (가운데 정렬)"
}, },
"11": { "11": {
"path": "//TEXT[CHAR[text()='{searchValue}']]/@CharShape", "path": "//TEXT[CHAR[text()='{searchValue}']]/@CharShape",
"searchValue": "구강건강관리",
"value": "맑은 고딕", "value": "맑은 고딕",
"points": 1, "points": 1,
"category": "FontName", "category": "Rectangle.FontName",
"item": "문구 (구강건강관리)/⑧ 글씨체 (맑은 고딕)" "item": "문구 (구강건강관리)/⑧ 글씨체 (맑은 고딕)"
}, },
"12": { "12": {
"path": "//CHARSHAPE[@Id=//RECTANGLE//TEXT[./CHAR[text()='{searchValue}']]/@CharShape]/@Height", "path": "//CHARSHAPE[@Id=//RECTANGLE//TEXT[./CHAR[text()='{searchValue}']]/@CharShape]/@Height",
"searchValue": "구강건강관리",
"value": "2300", "value": "2300",
"points": 1, "points": 1,
"category": "OneAnswer", "category": "Rectangle.OneAnswer",
"item": "문구 (구강건강관리)/⑨ 글씨크기 (2300)", "item": "문구 (구강건강관리)/⑨ 글씨크기 (2300)",
"desc": "1pt당 100" "desc": "1pt당 100"
}, },
"13": { "13": {
"path": "//PARASHAPE[@Id=//RECTANGLE//P[.//CHAR[text()='{searchValue}']]/@ParaShape]/@Align", "path": "//PARASHAPE[@Id=//RECTANGLE//P[.//CHAR[text()='{searchValue}']]/@ParaShape]/@Align",
"searchValue": "구강건강관리",
"value": "Center", "value": "Center",
"points": 1, "points": 1,
"category": "OneAnswer", "category": "Rectangle.OneAnswer",
"item": "문구 (구강건강관리)/⑩ 정렬 (가운데 정렬)" "item": "문구 (구강건강관리)/⑩ 정렬 (가운데 정렬)"
}, },
"14": { "14": {

View File

@@ -3,7 +3,7 @@ import difflib
import json import json
from pathlib import Path from pathlib import Path
import os import os
from lxml import etree as ET from lxml import etree
import re import re
from difflib import SequenceMatcher from difflib import SequenceMatcher
import pandas as pd import pandas as pd
@@ -133,32 +133,6 @@ 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가 존재하는지 확인 (어디에 있든 탐색)
# bookmarks = p.xpath('.//BOOKMARK')
# for bm in bookmarks:
# name = bm.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 parse_pages_by_bookmark(root): def parse_pages_by_bookmark(root):
""" """
BOOKMARK(Name="Page_X_start" ~ "Page_X_end") 사이의 <P> 요소들을 BOOKMARK(Name="Page_X_start" ~ "Page_X_end") 사이의 <P> 요소들을
@@ -201,7 +175,7 @@ class XMLScorer:
return full_text return full_text
try: try:
tree = ET.parse(xml_file) tree = etree.parse(xml_file)
root = tree.getroot() root = tree.getroot()
# XML문서 페이지 파싱 전처리 # XML문서 페이지 파싱 전처리
@@ -216,9 +190,9 @@ class XMLScorer:
# 차트 XML 파일이 없는 경우 0점 채점을 위헤 빈 XML 생성 # 차트 XML 파일이 없는 경우 0점 채점을 위헤 빈 XML 생성
if chart_xml is None: if chart_xml is None:
chart_tree = ET.fromstring('<xml></xml>') chart_tree = etree.fromstring('<xml></xml>')
else: else:
chart_tree = ET.fromstring(chart_xml) chart_tree = etree.fromstring(chart_xml)
# 결과값을 Dictionary로 저장 # 결과값을 Dictionary로 저장
# 하나의 xml파일 = 수험생 한명의 답안지 # 하나의 xml파일 = 수험생 한명의 답안지
@@ -275,6 +249,9 @@ class XMLScorer:
} }
try: try:
# 머릿말과 관련된 문항에서 1페이지에 머릿말이 없는 경우의 처리
# [1-25, 26, 27] 문항 'DIAT' 머릿말 채점시 1페이지에 머릿말이 없으면
# 채점하지 않고 0점 처리
if "Header" in (category or ""): if "Header" in (category or ""):
def has_elements(ptags, xpath): def has_elements(ptags, xpath):
for p in ptags: for p in ptags:
@@ -285,9 +262,27 @@ class XMLScorer:
page1_ptags = pages.get('Page_1', []) page1_ptags = pages.get('Page_1', [])
header_xpath = ".//HEADER//P" header_xpath = ".//HEADER//P"
has_page1_element = has_elements(page1_ptags, header_xpath) has_page1_header = has_elements(page1_ptags, header_xpath)
if not has_page1_element: if not has_page1_header:
user_answer = None
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
continue
has_page2_rectangle = False
if "Rectangle" in (category or ""):
def has_elements(ptags, xpath):
for p in ptags:
element_list = p.xpath(xpath) if xpath else []
if element_list:
return True
return False
page2_ptags = pages.get('Page_2', [])
rectangle_xpath = ".//RECTANGLE"
has_page2_rectangle = has_elements(page2_ptags, rectangle_xpath)
if not has_page2_rectangle:
user_answer = None user_answer = None
self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal") self.evaluate_answer(scoring, user_answer, right_answer, points, method="equal")
continue continue
@@ -459,12 +454,47 @@ class XMLScorer:
if scoring['points'] > 0: if scoring['points'] > 0:
break break
# 글상자 정렬 [2-10] 문항
# 2페이지의 글상자의 ParaShape ID를 동적으로 찾아서 채점
elif "TextBoxAlign" in (category or ""):
if has_page2_rectangle:
# 2페이지 내에서만 검색
search_root = etree.Element("Page_2")
for p in page2_ptags:
search_root.append(p)
rectangle_parashape_id = search_root.xpath(".//RECTANGLE/ancestor::P[last()]/@ParaShape")
else:
# 전체 root에서 검색
rectangle_parashape_id = root.xpath(".//RECTANGLE/ancestor::P[last()]/@ParaShape")
# ParaShape ID가 있는 경우에만 xpath 치환 & 실행
if rectangle_parashape_id:
xpath = xpath.replace('{rectangle_parashape_id}', rectangle_parashape_id[0])
items = root.xpath(xpath)
else:
# RECTANGLE이 없으면 items는 빈 리스트
items = [None]
for item in items:
user_answer = item
self.evaluate_answer(scoring, user_answer, right_answer, points)
if scoring['points'] > 0:
break
# 정답이 하나인 경우 # 정답이 하나인 경우
# elif (category or "") in ["OneAnswer", "ChartOneAnswer"]: # elif (category or "") in ["OneAnswer", "ChartOneAnswer"]:
elif "OneAnswer" in (category or ""): elif "OneAnswer" in (category or ""):
items = root.xpath(xpath) if xpath else [] if has_page2_rectangle:
items2 = root.xpath(xpath2) if xpath2 else [] search_root = etree.Element("Page_2")
for p in page2_ptags:
search_root.append(p)
items = search_root.xpath(xpath) if xpath else []
items2 = search_root.xpath(xpath2) if xpath2 else []
else:
items = root.xpath(xpath) if xpath else []
items2 = root.xpath(xpath2) if xpath2 else []
# 차트 XML에서 정답을 찾는 경우 # 차트 XML에서 정답을 찾는 경우
# 차트 종류가 # 차트 종류가
@@ -496,8 +526,15 @@ class XMLScorer:
break break
# [2-6] 테두리 이중실선 1.00mm # [2-6] 테두리 이중실선 1.00mm
elif (category or "") == "LineShape": elif "LineShape" in (category or ""):
line_shapes = root.xpath(xpath) if xpath else [] if has_page2_rectangle:
search_root = etree.Element("Page_2")
for p in page2_ptags:
search_root.append(p)
line_shapes = search_root.xpath(xpath) if xpath else []
else:
line_shapes = root.xpath(xpath) if xpath else []
user_answer = { user_answer = {
'Style': None, 'Style': None,
@@ -516,8 +553,16 @@ class XMLScorer:
break break
# 사용자 입력값이 mm단위인 경우 # 사용자 입력값이 mm단위인 경우
elif (category or "") == "mmSize": # elif (category or "") == "mmSize":
items = root.xpath(xpath) elif "mmSize" in (category or ""):
if has_page2_rectangle:
search_root = etree.Element("Page_2")
for p in page2_ptags:
search_root.append(p)
items = search_root.xpath(xpath)
else:
items = root.xpath(xpath)
# 오차범위 설정 # 오차범위 설정
# 한글 프로그램 내부에서 드물게 0mm이지만 1pt로 저장되는 경우가 있음 # 한글 프로그램 내부에서 드물게 0mm이지만 1pt로 저장되는 경우가 있음
# #
@@ -567,9 +612,17 @@ class XMLScorer:
self.evaluate_answer(scoring, user_answer, right_answer, points) self.evaluate_answer(scoring, user_answer, right_answer, points)
# 채점기준표 파일에 작성된 rgb값을 그대로 읽어와 HML파일 요소의 int형 rgb값과 비교 # 채점기준표 파일에 작성된 rgb값을 그대로 읽어와 HML파일 요소의 int형 rgb값과 비교
elif (category or "") == "Color": elif "Color" in (category or ""):
items = root.xpath(xpath) if xpath else [] if has_page2_rectangle:
items2 = root.xpath(xpath2) if xpath2 else [] search_root = etree.Element("Page_2")
for p in page2_ptags:
search_root.append(p)
items = search_root.xpath(xpath) if xpath else []
items2 = search_root.xpath(xpath2) if xpath2 else []
else:
items = root.xpath(xpath) if xpath else []
items2 = root.xpath(xpath2) if xpath2 else []
rgb_text = right_answer rgb_text = right_answer
@@ -605,7 +658,14 @@ class XMLScorer:
# 폰트명 # 폰트명
elif "FontName" in (category or ""): elif "FontName" in (category or ""):
charshape_list = root.xpath(xpath) if has_page2_rectangle:
search_root = etree.Element("Page_2")
for p in page2_ptags:
search_root.append(p)
charshape_list = search_root.xpath(xpath)
else:
charshape_list = root.xpath(xpath)
# 문자속성이 없는 경우 # 문자속성이 없는 경우
if not charshape_list: if not charshape_list:
@@ -978,7 +1038,7 @@ class XMLScorer:
onePersonResult['total_score'] = self.total_score onePersonResult['total_score'] = self.total_score
return onePersonResult return onePersonResult
except ET.ParseError as e: except etree.ParseError as e:
return { return {
'filename': os.path.basename(xml_file), 'filename': os.path.basename(xml_file),
'error': f"XML 파싱 오류: {str(e)}", 'error': f"XML 파싱 오류: {str(e)}",
@@ -986,7 +1046,7 @@ class XMLScorer:
} }
def binary_to_chartxml(self, xml_path): def binary_to_chartxml(self, xml_path):
tree = ET.parse(xml_path) tree = etree.parse(xml_path)
root = tree.getroot() root = tree.getroot()
binary_data = root.xpath('//BINDATA[@Id=//BINITEM[@Format="OLE"]/@BinData]/text()') binary_data = root.xpath('//BINDATA[@Id=//BINITEM[@Format="OLE"]/@BinData]/text()')
@@ -1038,8 +1098,8 @@ class XMLScorer:
# 2. 공백제거, 특정 형식 제거 # 2. 공백제거, 특정 형식 제거
# 3. 리스트를 문자열로 변환 # 3. 리스트를 문자열로 변환
user_answer_root = ET.parse(user_answer_file).getroot() user_answer_root = etree.parse(user_answer_file).getroot()
correct_answer_root = ET.parse(correct_answer_file).getroot() correct_answer_root = etree.parse(correct_answer_file).getroot()
# xpath로 바이너리 부분추출 # xpath로 바이너리 부분추출
user_input_text = user_answer_root.xpath('//CHAR//text()[not(ancestor::HEADER) and not(ancestor::TABLE)]') user_input_text = user_answer_root.xpath('//CHAR//text()[not(ancestor::HEADER) and not(ancestor::TABLE)]')
@@ -1054,7 +1114,7 @@ class XMLScorer:
# 차트 XML에서 차트제목 추출 # 차트 XML에서 차트제목 추출
if chart_xml is not None: if chart_xml is not None:
chart_xml_tree = ET.fromstring(chart_xml) chart_xml_tree = etree.fromstring(chart_xml)
ns = {'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart', ns = {'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart',
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'} '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' xpath_expr = '/c:chartSpace/c:chart/c:title/c:tx/c:rich/a:p/a:r/a:t'
@@ -1325,12 +1385,12 @@ def main():
exam_types = [ exam_types = [
# 'A', # 'A',
# 'B', # 'B',
# 'C', 'C',
'D', # 'D',
] ]
test_mode = False # test_mode = False
# test_mode = True #/TEST 폴더 채점시 test_mode = True #/TEST 폴더 채점시
output_excel_paths = [] output_excel_paths = []
for exam_type in exam_types: for exam_type in 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":"//RECTANGLE//CHAR[text()='구강건강관거리']/ancestor::RECTANGLE/SHAPEOBJECT/SIZE/@Width"},{"kind":2,"language":"xpath","value":"//CHAR[contains(text(),'예방')][contains(text(),'豫防')]"},{"kind":2,"language":"xpath","value":"//TEXT[CHAR[text()='DIAT']]"},{"kind":2,"language":"xpath","value":"//HEADER//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":"//CHAR[contains(string(.), '※')]/descendant-or-self::text()"},{"kind":2,"language":"xpath","value":"//P[@ParaShape=\"17\"]/TEXT[@CharShape='7']//CHAR[string(.)]"},{"kind":2,"language":"xpath","value":"//CHAR[contains(string(.), '기타')]/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":"//PARASHAPE[@Id=//RECTANGLE/ancestor::P[last()]/@ParaShape]/@Align"},{"kind":2,"language":"xpath","value":"//RECTANGLE//LINESHAPE"},{"kind":2,"language":"xpath","value":"//RECTANGLE/SHAPEOBJECT/SIZE/@Width"},{"kind":2,"language":"xpath","value":"//HEADER//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":"//CHAR[contains(string(.), '※')]/descendant-or-self::text()"},{"kind":2,"language":"xpath","value":"//P[@ParaShape=\"17\"]/TEXT[@CharShape='7']//CHAR[string(.)]"},{"kind":2,"language":"xpath","value":"//CHAR[contains(string(.), '기타')]/text()"}]