From 9aa1425d815a226b991d7892699c951ac84e153e Mon Sep 17 00:00:00 2001 From: dragdra Date: Fri, 27 Jun 2025 17:52:33 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B0=ED=94=BD=20[4-3][4-4]=20=EC=B1=84?= =?UTF-8?q?=EC=A0=90=EC=8B=9C=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20=EA=B2=80=EC=83=89=ED=95=A0=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=9C=A0=EC=82=AC=EB=8F=84=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DIC_2504B.json | 74 +- findSimilarString.js | 18 +- gpdpScoring.js | 980 ++++++++++-------- package-lock.json | 8 + package.json | 1 + psdExport_2.js | 4 +- z.xbook | 2 +- .../2504/excel_채점기준표/DPI_2504B.xlsx | Bin 18511 -> 18504 bytes 8 files changed, 640 insertions(+), 447 deletions(-) diff --git a/DIC_2504B.json b/DIC_2504B.json index 32b2c1e..fa2cdb4 100644 --- a/DIC_2504B.json +++ b/DIC_2504B.json @@ -498,8 +498,8 @@ }, "4": { "type": "layer.Effects", - "ele": "//Layer[Name[@value='{layer}']]/Effects/Item", - "layer": "Flower", + "ele": "//Layer[Name[@value='{search}']]/Effects/Item", + "search": "Flower", "value": { "name": "세피아", "option": { @@ -557,19 +557,21 @@ "point": 6 }, "11": { - "ele": "none", + "type": "none", + "ele": "", "point": 0, "desc": "기본설정" }, "12": { - "ele": "none", + "type": "none", + "ele": "", "point": 0, "desc": "파일명 확인" } }, "5": { "1": { - "type": "multi", + "type": "canvas.Size", "ele": "//Document/Width/@value | //Document/Height/@value", "value": [ "650", @@ -579,7 +581,8 @@ "desc": "캔버스 사이즈 650*450" }, "2": { - "ele": "none", + "type": "none", + "ele": "", "point": 5, "desc": "배경색 문항은 채점 불가" }, @@ -587,10 +590,12 @@ "type": "exists", "ele": "//Layer/MaskOpType/@value", "value": "Layering", - "point": 6 + "point": 6, + "desc": "레이어 마스크 설정 확인" }, "4": { - "ele": "none", + "type": "none", + "ele": "", "point": 6, "desc": "가로방향 흐릿하게 문항은 채점 불가" }, @@ -601,7 +606,7 @@ "point": 3 }, "6": { - "type": "size", + "type": "shape.size", "ele": "//Layer//op_points", "value": { "width": 400, @@ -611,13 +616,13 @@ "desc": "레이어 쉐이프 X, Y 좌표를 가지고 너비, 높이 계산하여 정답 채점" }, "7": { - "type": "gradient", + "type": "gradient.color", "ele": "//Layer/Shapes/Shape", "startColor": "gradient_start_color/@value", "endColor": "gradient_end_color/@value", "value": { "startColor": "ffe000", - "endColor": "34a159" + "endColor": "34A159" }, "point": 6 }, @@ -647,11 +652,10 @@ "point": 3 }, "12": { - "type": "color", + "type": "text.color", "ele": "//Layer//Shape[shape_type/@value='TEXT'][contains(draw_type/@value, 'Interior')]/secondary_color/@value", - "value": "b46ef8", - "point": 3, - "desc": "색상 코드 비교 시 소문자로 입력할 것" + "value": "b46Ef8", + "point": 3 }, "13": { "type": "exists", @@ -660,14 +664,15 @@ "point": 3 }, "14": { - "type": "color", + "type": "text.color", "ele": "//Layer//Shape[shape_type/@value='TEXT'][contains(draw_type/@value, 'Outline')]/primary_color/@value", "value": "ffffff", - "point": 3, - "desc": "색상 코드 비교 시 소문자로 입력할 것" + "point": 3 }, "15": { - "ele": "//Layer[MaskOpType/@value='Clipping'][last()]", + "type": "exists", + "ele": "//Layer/MaskOpType/@value", + "value": "Clipping", "point": 6, "desc": "클리핑 마스크 항목은 별도 레이어로 추가되고 해당 속성을 추가해놓은 레이어가 있는지 여부 체크 함" }, @@ -675,11 +680,15 @@ "type": "exists", "ele": "//Layer/Shapes/Shape/shape_type/@value", "value": "RECTANGLE", - "point": 3 + "point": 3, + "desc": { + "사각형": "RECTANGLE" + } }, "17": { - "type": "size", - "ele": "//Layer//op_points", + "type": "clipping.size", + "ele": "//Layer//Shape[shape_type/@value='{option}']//op_points", + "option": "RECTANGLE", "value": { "width": 150, "height": 150 @@ -689,27 +698,32 @@ }, "18": { "type": "exists", - "ele": "//Layer//outline_peninfo/Width/@value", + "ele": "//Layer//Shape[shape_type/@value='{option}']/outline_peninfo/Width/@value", + "option": "RECTANGLE", "value": "7", "point": 3 }, "19": { - "type": "color", - "ele": "//Layer//Shape[contains(draw_type/@value, 'Outline')]/primary_color/@value", + "type": "clipping.color", + "ele": "//Layer//Shape[shape_type/@value='{option}' and contains(draw_type/@value, 'Outline')]/primary_color/@value", + "option": "RECTANGLE", "value": "e8e88e", "point": 3, - "desc": "색상 코드 비교 시 소문자로 입력할 것(채우기:secondary_color, 외곽선:primary_color)" + "desc": "채우기:secondary_color, 외곽선:primary_color" }, "20": { "type": "shadow", - "ele": { + "ele": "//Layer//Shape[shape_type/@value='{option}']", + "ele2": { "shadow": "//Layer//Shape[contains(draw_type/@value, 'Shadow')]", "width": "//Layer//Shape[contains(draw_type/@value, 'Shadow')]/shadow_width/@value", "distance": "//Layer//Shape[contains(draw_type/@value, 'Shadow')]/shadow_distance/@value", "blur": "//Layer//Shape[contains(draw_type/@value, 'Shadow')]/shadow_blur/@value", "angle": "//Layer//Shape[contains(draw_type/@value, 'Shadow')]/shadow_angle/@value" }, + "option": "RECTANGLE", "value": { + "shadow": true, "width": "3", "distance": "5", "blur": "1", @@ -719,12 +733,14 @@ "desc": "그림자 속성이 있는 경우 그림자 속성의 너비, 거리, 흐림 정도, 각도를 비교하여 정답 채점" }, "21": { - "ele": "none", + "type": "none", + "ele": "", "point": 0, "desc": "기본설정" }, "22": { - "ele": "none", + "type": "none", + "ele": "", "point": 0, "desc": "파일명 확인" } diff --git a/findSimilarString.js b/findSimilarString.js index f94f966..ddbe3b9 100644 --- a/findSimilarString.js +++ b/findSimilarString.js @@ -11,16 +11,24 @@ const stringSimilarity = require("string-similarity"); */ function findSimilarString(xmlDoc, targetString, threshold = 0.8) { // XML 내부의 비교 대상 텍스트 리스트 찾기 - function getTextNodes(xmlDoc, stringList = []) { - const stringNodes = xpath.select("//CRCUnitArr/@Name", xmlDoc); - stringNodes.forEach(stringNode => { - stringList.push(stringNode.value); + function getTextNodes(xmlDoc, paths = ["//CRCUnitArr/@Name"]) { + const stringList = []; + + paths.forEach(path => { + const nodes = xpath.select(path, xmlDoc); + nodes.forEach(node => { + stringList.push(node.value); + }); }); + return stringList; } // XML에서 모든 텍스트 추출 - const stringList = getTextNodes(xmlDoc); + const stringList = getTextNodes(xmlDoc, [ + "//CRCUnitArr/@Name", + "//Layer/Name/@value", + ]); // 유사도 비교하여 가장 유사한 문자열 찾기 let bestMatch = null; diff --git a/gpdpScoring.js b/gpdpScoring.js index 8fb3345..eefc831 100644 --- a/gpdpScoring.js +++ b/gpdpScoring.js @@ -1,5 +1,8 @@ const xpath = require('xpath'); const { DOMParser } = require('xmldom'); +const _ = require('lodash'); + +const findSimilarString = require('./findSimilarString'); function parseColorToHex(colorString) { // 정규식을 사용하여 B, G, R, A 값 추출 @@ -49,47 +52,69 @@ function getGpdpScore(gpdpData, scoringJson, index) { const { tolerance = 0, // 숫자 비교 시 허용 오차 type = 'auto', // 'auto' | 'number[]' | 'string[]' | 'object' | 'primitive' + partial = false, } = options; if (type === 'force-correct') { - isEqual = true; + score = point; } - else if (Array.isArray(right) && Array.isArray(user)) { - if (right.length === user.length) { - if - (type === 'number[]' || - (type === 'auto' && typeof right[0] === 'number')) { - isEqual = right.every((val, idx) => Math.abs(val - user[idx]) <= tolerance); + // 객체 비교 + else if ( + (type === 'object' || (type === 'auto' && typeof right === 'object' && typeof user === 'object')) + && !Array.isArray(right) + ) { + if (partial) { + const keys = Object.keys(right); + const partialPoint = point / keys.length; + for (const k of keys) { + if (_.isEqual(user[k], right[k])) { + score += partialPoint; + } } - else if - (type === 'string[]' || - (type === 'auto' && typeof right[0] === 'string')) { - isEqual = right.every((val, idx) => val === user[idx]); + } else { + if (_.isEqual(user, right)) { + score = point; } } } - else if - (type === 'object' || - (type === 'auto' && typeof right === 'object' && typeof user === 'object')) { - isEqual = JSON.stringify(user) === JSON.stringify(right); - } - else { - isEqual = user == right; // primitive 비교 + + // 배열 비교 + else if (Array.isArray(right) && Array.isArray(user)) { + if (right.length === user.length) { + if (type === 'number[]' || (type === 'auto' && typeof right[0] === 'number')) { + if (right.every((val, idx) => val === user[idx])) { + score = point; + } + } else if (type === 'string[]' || (type === 'auto' && typeof right[0] === 'string')) { + if (right.every((val, idx) => val === user[idx])) { + score = point; + } + } + } } - if (isEqual) { - score = point; - console.log('작성답안: ', user); - console.log('>⭕ 정답: ', right); - } else { - console.log('작성답안: ', user); - console.log('>❌ 오답: ', right); + // 원시값 비교 + else { + if (user == right) { + score = point; + } + } + + console.log(`▶ 작성답안:`, user); + if (score > 0) { + if (partial) + console.log('⭕부분정답:', right, `(${score}/${point})`); + else + console.log('⭕ 정답 : ', right, `(${score}/${point})`); + } + else { + console.log(`❌ 오답:`, right); } scoringResult[key] = score; - // totalScore += score; return score; } + const gpdpXmlDoc = gpdpData; const scoringResult = {}; @@ -109,9 +134,10 @@ function getGpdpScore(gpdpData, scoringJson, index) { // 채점기준표 문항별 분류 for (const key in scoringData) { + console.log(`[❔]문제번호 : [${index}-${key}]`) + let ele = scoringData[key].ele; let ele2 = scoringData[key].ele2; - let existEle = scoringData[key].existEle; const rightAnswer = scoringData[key].value; const point = scoringData[key].point; const type = scoringData[key].type; @@ -127,19 +153,26 @@ function getGpdpScore(gpdpData, scoringJson, index) { ele = typeof ele === 'string' ? ele.replace(/{option}/g, option) : ele; ele = typeof ele === 'string' ? ele.replace(/{style}/g, style) : ele; + // search 값이 undefined 아니면 ele의 {search}부분을 search로 치환 + /** + * JSON파일 곰믹스 5번문항/22번 문항 + * type : "video" 인 항목들 + * GPString태그 VID7속성 찾는 xpath구문 + * CRCUnitArr태그 Name속성 찾는 구문으로 변환 + * > 멀티라인 텍스트 유사도 판별하기 어려움 + */ if (search !== undefined) { - let result = findSimilarString(gpdpXmlDoc, search, 0.8) - // xpath 내부 "(큰따옴표) 필터링 + let result = findSimilarString(gpdpXmlDoc, search, 0.8); if (result !== null) { result = result.replace(/"/g, "'"); + search = result; + ele = ele?.replace(/{search}/g, search); } - ele = ele.replace(/{search}/g, result); - if (existEle !== undefined) { - existEle = existEle.replace(/{search}/g, result); + else { + ele = ele?.includes('{search}') ? null : ele; } } - console.log(`❔문제번호 : [4-${key}]`) if (type === "none") { console.log("❌ 채점하지 않음"); @@ -162,407 +195,534 @@ function getGpdpScore(gpdpData, scoringJson, index) { else if (type === "layer.Exists") { const layerNameList = xpath.select(ele, gpdpXmlDoc); const layerNames = layerNameList.map(layer => layer.value); - console.log("🚀 ~ getGpdpScore ~ layerNames:", layerNames); + let isMatched = false - // userAnswer = layerNames.find(name => name === rightAnswer); - for (const layerName of layerNames) { - if (layerName.trim().toLowerCase() === rightAnswer.trim().toLowerCase()) { - userAnswer = layerName; - break; - } + // for (const layerName of layerNames) { + // if (layerName.trim().toLowerCase() === rightAnswer.trim().toLowerCase()) { + // userAnswer = layerName; + // isMatched = true; + // break; + // } + // } + + // if (isMatched) { + // totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); + // } + + let result = findSimilarString(gpdpXmlDoc, rightAnswer, 0.8); + if (result !== null) { + userAnswer = result; + isMatched = true; + } + + if (isMatched) { + totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult, { + type: 'force-correct' + }); + } + else { + totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); } - totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); - continue; } // [1-4] 사진1 > 조정 else if (type === "layer.Effects") { - const effects = xpath.select(ele, gpdpXmlDoc); + const effects = xpath.select(ele, gpdpXmlDoc); - let isMatched = false; - for (const item of effects) { - const name = xpath.select1('Name/@value', item)?.value; - const effectData = xpath.select1(`EffectData`, item); + let isMatched = false; + for (const item of effects) { + const name = xpath.select1('Name/@value', item)?.value; + const effectData = xpath.select1(`EffectData`, item); - // 동일한 이펙트 요소만 검사 - if (rightAnswer['name'] !== name) { - continue; + // 동일한 이펙트 요소만 검사 + if (rightAnswer['name'] !== name) { + continue; + } + + userAnswer = { + name: name, + option: {}, + } + if (name === '흑백') { + const Intensity = xpath.select1('Intensity/@value', effectData)?.value; + + const optionKeys = Object.keys(rightAnswer['option']); + if (optionKeys.includes('강도')) userAnswer['option']['강도'] = Intensity; + } + else if (name === '밝기/대비') { + const brightness = xpath.select1('brightness/@value', effectData)?.value; + const contrast = xpath.select1('contrast/@value', effectData)?.value; + + const optionKeys = Object.keys(rightAnswer['option']); + if (optionKeys.includes('밝기')) userAnswer['option']['밝기'] = brightness; + if (optionKeys.includes('대비')) userAnswer['option']['대비'] = contrast; + } + else if (name === '노출') { + const ExposureValue = xpath.select1('ExposureValue/@value', effectData)?.value; + + const optionKeys = Object.keys(rightAnswer['option']); + if (optionKeys.includes('노출')) userAnswer['option']['노출'] = ExposureValue; + } + else if (name === '색조/채도') { + const hue = xpath.select1('hue/@value', effectData)?.value; + const saturation = xpath.select1('saturation/@value', effectData)?.value; + const lightness = xpath.select1('lightness/@value', effectData)?.value; + + const optionKeys = Object.keys(rightAnswer['option']); + if (optionKeys.includes('색조')) userAnswer['option']['색조'] = hue; + if (optionKeys.includes('채도')) userAnswer['option']['채도'] = saturation; + if (optionKeys.includes('명도')) userAnswer['option']['명도'] = lightness; + } + else if (name === '감마') { + const lift = xpath.select1('Lift/@value', effectData)?.value; + const gamma = xpath.select1('Gamma/@value', effectData)?.value; + const gain = xpath.select1('Gain/@value', effectData)?.value; + + const optionKeys = Object.keys(rightAnswer['option']); + if (optionKeys.includes('리프트')) userAnswer['option']['리프트'] = lift; + if (optionKeys.includes('감마')) userAnswer['option']['감마'] = gamma; + if (optionKeys.includes('게인')) userAnswer['option']['게인'] = gain; + } + else if (name === '세피아') { + const u = xpath.select1('U/@value', effectData)?.value; + const v = xpath.select1('V/@value', effectData)?.value; + + const optionKeys = Object.keys(rightAnswer['option']).map(key => key.toUpperCase()); + if (optionKeys.includes('U')) userAnswer['option']['U'] = u; + if (optionKeys.includes('V')) userAnswer['option']['V'] = v; + } + else if (name === '생동감') { + const vibranceValue = xpath.select1('VibranceValue/@value', effectData)?.value; + // 생동감 옵션값이 프로그램에서 적용한 값에 오차가 발생하는 경우가 있음 + // 곰픽>XML / 30>29 / 40>39 + // 설정한 값 그대로 적용되는 경우도 있어서 오차범위 2로 설정 + const userValue = parseInt(vibranceValue, 10); + const rightValue = parseInt(rightAnswer.option['생동감'], 10); + + if (Math.abs(rightValue - userValue) <= 2) { + const optionKeys = Object.keys(rightAnswer['option']); + if (optionKeys.includes('생동감')) { + userAnswer['option']['생동감'] = rightValue.toString(); + } } + } + + for (const key in rightAnswer.option) { + // 속성값이 정답과 다른 경우가 있으면 오답처리 + if (rightAnswer.option[key] !== userAnswer.option[key]) { + isMatched = false; + break; + } + else { + isMatched = true; + } + } + + // 속성값이 하나라도 일치하지 않으면 오답 + if (isMatched === false) { + break; + } + } + totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); + continue; + } + + // + else if (type === "exists") { + const existsValues = xpath.select(ele, gpdpXmlDoc); + let isMatched = false; + + for (const v of existsValues) { + // 하나라도 일치하면 정답 + if (v.value === rightAnswer) { + userAnswer = v.value; + isMatched = true; + break; + } + } + // if (isMatched) { + + // } + // else { + + // } + totalScore = compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); + } + + + // else if (type === "shape.size") { + else if (type.includes("size")) { + const items = xpath.select(ele, gpdpXmlDoc); + let isMatched = false; + + // 각 Item 요소별 x,y 좌표 시작점과 끝점의 거리를 계산해 정답과 비교 + for (const item of items) { + const x1 = Number(xpath.select1('Item[1]/X/@value', item)?.value); + const y1 = Number(xpath.select1('Item[1]/Y/@value', item)?.value); + + const x2 = Number(xpath.select1('Item[last()]/X/@value', item)?.value); + const y2 = Number(xpath.select1('Item[last()]/Y/@value', item)?.value); + + const width = Math.round(Math.abs(x2 - x1)); + const height = Math.round(Math.abs(y2 - y1)); + + userAnswer = { + width: width, + height: height, + }; + + // 하나라도 일치하면 정답 + if (JSON.stringify(userAnswer) == JSON.stringify(rightAnswer)) { + isMatched = true; + break; + } + } + + totalScore = compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); + continue; + } + + // [1-8] + else if (type.includes("color")) { + const items = xpath.select(ele, gpdpXmlDoc); + let normalizedAnswer = null; + let isMatched = false; + + for (const item of items) { + if (type.includes('gradient')) { + const startColorXpath = scoringData[key].startColor; + const endColorXpath = scoringData[key].endColor; + + const startColorRGB = xpath.select1(startColorXpath, item).value; + const endColorRGB = xpath.select1(endColorXpath, item).value; + + const startColor = parseColorToHex(startColorRGB); + const endColor = parseColorToHex(endColorRGB); userAnswer = { - name: name, - option: {}, - } - if (name === '흑백') { - const Intensity = xpath.select1('Intensity/@value', effectData)?.value; - - const optionKeys = Object.keys(rightAnswer['option']); - if (optionKeys.includes('강도')) userAnswer['option']['강도'] = Intensity; - } - else if (name === '밝기/대비') { - const brightness = xpath.select1('brightness/@value', effectData)?.value; - const contrast = xpath.select1('contrast/@value', effectData)?.value; - - const optionKeys = Object.keys(rightAnswer['option']); - if (optionKeys.includes('밝기')) userAnswer['option']['밝기'] = brightness; - if (optionKeys.includes('대비')) userAnswer['option']['대비'] = contrast; - } - else if (name === '노출') { - const ExposureValue = xpath.select1('ExposureValue/@value', effectData)?.value; - - const optionKeys = Object.keys(rightAnswer['option']); - if (optionKeys.includes('노출')) userAnswer['option']['노출'] = ExposureValue; - } - else if (name === '색조/채도') { - const hue = xpath.select1('hue/@value', effectData)?.value; - const saturation = xpath.select1('saturation/@value', effectData)?.value; - const lightness = xpath.select1('lightness/@value', effectData)?.value; - - const optionKeys = Object.keys(rightAnswer['option']); - if (optionKeys.includes('색조')) userAnswer['option']['색조'] = hue; - if (optionKeys.includes('채도')) userAnswer['option']['채도'] = saturation; - if (optionKeys.includes('명도')) userAnswer['option']['명도'] = lightness; - } - else if (name === '감마') { - const lift = xpath.select1('Lift/@value', effectData)?.value; - const gamma = xpath.select1('Gamma/@value', effectData)?.value; - const gain = xpath.select1('Gain/@value', effectData)?.value; - - const optionKeys = Object.keys(rightAnswer['option']); - if (optionKeys.includes('리프트')) userAnswer['option']['리프트'] = lift; - if (optionKeys.includes('감마')) userAnswer['option']['감마'] = gamma; - if (optionKeys.includes('게인')) userAnswer['option']['게인'] = gain; - } - else if (name === '세피아') { - const u = xpath.select1('U/@value', effectData)?.value; - const v = xpath.select1('V/@value', effectData)?.value; - - const optionKeys = Object.keys(rightAnswer['option']).map(key => key.toUpperCase()); - if (optionKeys.includes('U')) userAnswer['option']['U'] = u; - if (optionKeys.includes('V')) userAnswer['option']['V'] = v; - } - else if (name === '생동감') { - const vibranceValue = xpath.select1('VibranceValue/@value', effectData)?.value; - // 생동감 옵션값이 프로그램에서 적용한 값에 오차가 발생하는 경우가 있음 - // 곰픽>XML / 30>29 / 40>39 - // 설정한 값 그대로 적용되는 경우도 있어서 오차범위를 설정 - const userValue = parseInt(vibranceValue, 10); - const rightValue = parseInt(rightAnswer.option['생동감'], 10); - - if (Math.abs(rightValue - userValue) <= 2) { - const optionKeys = Object.keys(rightAnswer['option']); - if (optionKeys.includes('생동감')) { - userAnswer['option']['생동감'] = rightValue.toString(); - } - } + startColor: startColor, + endColor: endColor, } - for (const key in rightAnswer.option) { - // 속성값이 정답과 다른 경우가 있으면 오답처리 - if (rightAnswer.option[key] !== userAnswer.option[key]) { - isMatched = false; - break; - } - else { - isMatched = true; - } + // JSON파일에서 대문자로 입력된 경우 소문자로 변환 + normalizedAnswer = { + startColor: rightAnswer.startColor.toLowerCase(), + endColor: rightAnswer.endColor.toLowerCase(), } - // 속성값이 하나라도 일치하지 않으면 오답 - if (isMatched === false) { + // 하나라도 일치하면 정답 + if (JSON.stringify(userAnswer) == JSON.stringify(normalizedAnswer)) { + isMatched = true; break; } } - totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); + + // else if (type.includes('shape') || type.includes('text') || type.includes('clipping')) { + else { + const color = parseColorToHex(item.value); + userAnswer = color; + normalizedAnswer = rightAnswer.toLowerCase?.(); + + // 하나라도 일치하면 정답 + if (userAnswer === normalizedAnswer) { + isMatched = true; + break; + } + } + } + totalScore = compareAndScore(userAnswer, normalizedAnswer, point, key, scoringResult); + } + + else if (type === 'layer.blend.opacity') { + const layers = xpath.select(ele, gpdpXmlDoc); + let isMatched = false; + + for (const layer of layers) { + const blendop = xpath.select1('BlendOp/@value', layer).value; + const opacity = xpath.select1('Opacity/@value', layer).value; + + userAnswer = { + BlendOp: blendop, + Opacity: opacity, + } + + // 하나라도 일치하면 정답 + if (JSON.stringify(userAnswer) == JSON.stringify(rightAnswer)) { + isMatched = true; + break; + } + } + + totalScore = compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); + continue; + } + + // [5-20] + else if (type === 'shadow') { + const shapes = xpath.select(ele, gpdpXmlDoc); + + for (const shape of shapes) { + // 그림자 설정 여부 + const shadowExists = xpath.select1('contains(draw_type/@value, "Shadow")', shape); + // Shadow 옵션이 있다면 + if (shadowExists) { + // 두께 + const width = xpath.select1('shadow_width/@value', shape).value; + // 거리 + const distance = xpath.select1('shadow_distance/@value', shape).value; + // 분산도 + const blur = xpath.select1('shadow_blur/@value', shape).value; + // 각도 + const angle = xpath.select1('shadow_angle/@value', shape).value; + + userAnswer = { + shadow: shadowExists, + width: width, + distance: distance, + blur: blur, + angle: angle, + } + } + else { + userAnswer = { + shadow: shadowExists, + width: null, + distance: null, + blur: null, + angle: null, + } + + } + } + console.log("🚀 ~ userAnswer:", userAnswer); + console.log("🚀 ~ rightAnswer : ", rightAnswer) + totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult, { + partial: true + }) + continue; + } + + else if (type == "boolean") { + const items = xpath.select(ele, gpdpXmlDoc); + + // xpath 결과값을 반환하는 요소가 없을 경우 + if (!items) { + scoringResult[key] = 0; + console.log("❌ 찾는 요소 없음"); + } + else { + totalScore += point; + scoringResult[key] = point; + console.log("✅ 찾는 요소 존재함"); + } + } + + // 이펙트 효과의 이름과 속성값을 비교 + else if (type == "effects") { + const items = xpath.select(ele, gpdpXmlDoc); + let matched = false; + + // 각 Item 요소별 이름과 속성값을 구하고 정답과 비교 + for (const item of items) { + const name = xpath.select1('Name/@value', item)?.value; + const attr = xpath.select1(`EffectData/${option?.replace(/"/g, '')}/@value`, item)?.value; + + if (name === rightAnswer[0] && attr === rightAnswer[1]) { + totalScore += point; + scoringResult[key] = point; + matched = true; + console.log("✅ 정답 일치:", rightAnswer); + break; + } + } + + if (!matched) { + scoringResult[key] = 0; + console.log("❌ 정답 없음:", rightAnswer); + } + } + + else if (type == "multiValue") { + if (Array.isArray(rightAnswer)) { + const result = ele ? xpath.select(ele, gpdpXmlDoc) : []; + const resultValues = Array.isArray(result) ? result.map(r => (typeof r === 'object' ? r.value : r)) : [result]; + console.log("🚀 ~ getGpdpScore ~ resultValues:", resultValues) + + const groupSize = rightAnswer.length; + const groupedResult = []; + for (let i = 0; i < resultValues.length; i += groupSize) { + groupedResult.push(resultValues.slice(i, i + groupSize)); + } + console.log("🚀 ~ getGpdpScore ~ groupedResult:", groupedResult) + + // 배열 비교 함수 + function arraysEqual(arr1, arr2) { + if (arr1.length !== arr2.length) return false; + return arr1.every((value, index) => value === arr2[index]); + } + + // groupedResult 내부 배열에서 rightAnswer와 일치하는 배열이 있는지 확인 + const isMatch = groupedResult.some(group => arraysEqual(group, rightAnswer)); + + if (isMatch) { + totalScore += point; + scoringResult[key] = point; + console.log("🚀 ~ 정답 포함"); + } else { + scoringResult[key] = 0; + console.log("🚀 ~ 오답"); + } + } + } + + else if (type == "exact") { + let result = xpath.select(ele, gpdpXmlDoc); + if (result.length == 0) { + scoringResult[key] = 0; + console.log('ele not found'); + continue; + } + if (result[0].value === rightAnswer) { + totalScore += point; + scoringResult[key] = point; + } else { + scoringResult[key] = 0; + console.log('ele not matched, ' + result[0].value); + } + } + + + else if (type == "multi") { + try { + const result = xpath.select(ele, gpdpXmlDoc); + let isSame = true; + // console.log(`ele: ${ele}, value: ${value} result: ${result}`); + + if (result.length == 0) { + console.log('result length 0'); + scoringResult[key] = 0; + continue; + } + + result.forEach((v, i) => { + // value[i] 값이 정수형인 경우에는 float로 변환하여 비교 + // 정수형 v값을 float 형으로 변환하고 소수점 3자리까지 버림 + let temp = v.value; + let answer = rightAnswer[i]; + + if (Number.isFinite(rightAnswer[i]) && !Number.isInteger(rightAnswer[i])) { + temp = parseFloat(v.value); + answer = parseFloat(rightAnswer[i]); + // 소수점 3자리까지 버림 + temp = Math.floor(temp * 1000) / 1000; + } + // answer 문자열 중 : 가 포함되어 있다면 각각 분리하고 그 값의 차이를 구함 + if (typeof answer == "string" && answer.indexOf(':') > -1) { + const [answerStart, answerEnd] = answer.split(':').map(Number); + const [tempStart, tempEnd] = temp.split(':').map(Number); + answer = answerEnd - answerStart; + temp = tempEnd - tempStart; + } + + console.log(`temp: ${temp} answer: ${answer}`); + if (answer !== temp) { + console.log(`answer !== temp`); + isSame = false; + } + }); + totalScore += isSame ? point : 0; + scoringResult[key] = isSame ? point : 0; + } catch (e) { + console.log('err :', e); + scoringResult[key] = 0; + } + } + else if (type == "gradient") { + const items = xpath.select(ele, gpdpXmlDoc); + const startColorXpath = scoringData[key].startColor; + const endColorXpath = scoringData[key].endColor; + let matched = false; + + for (const item of items) { + const startColor = parseColorToHex(xpath.select1(startColorXpath, item)?.value); + const endColor = parseColorToHex(xpath.select1(endColorXpath, item)?.value); + + console.log(startColor + ":" + rightAnswer["startColor"], endColor + ":" + rightAnswer["endColor"]); + + if (startColor === rightAnswer["startColor"] && endColor === rightAnswer["endColor"]) { + totalScore += point; + scoringResult[key] = point; + matched = true; + console.log("✅ 정답 일치:", rightAnswer); + break; + } + } + if (!matched) { + scoringResult[key] = 0; + console.log("❌ 정답 없음:", rightAnswer); + } + } + // 그림자 속성이 있는지 여부 파악해서 그림자 속성 별로 점수 1 점씩 부여 + else if (type == "shadow") { + + const result = xpath.select(ele["shadow"], gpdpXmlDoc); + let shadowScore = 0; + if (result.length == 0) { + scoringResult[key] = 0; + console.log('shadow not found'); continue; } + shadowScore += 1; + const width = xpath.select(ele["width"], gpdpXmlDoc); + const distance = xpath.select(ele["distance"], gpdpXmlDoc); + const blur = xpath.select(ele["blur"], gpdpXmlDoc); + const angle = xpath.select(ele["angle"], gpdpXmlDoc); - // [1-6][1-7] - else if (type === "exists") { - const existsValues = xpath.select(ele, gpdpXmlDoc); - - for (const v of existsValues) { - if (v.value === rightAnswer) { - userAnswer = v.value; - break; - } - } - totalScore = compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); - } - - - else if (type === "shape.size") { - const items = xpath.select(ele, gpdpXmlDoc); - - // 각 Item 요소별 x,y 좌표 시작점과 끝점의 거리를 계산해 정답과 비교 - for (const item of items) { - const x1 = Number(xpath.select1('Item[1]/X/@value', item)?.value); - const x2 = Number(xpath.select1('Item[last()]/X/@value', item)?.value); - const y1 = Number(xpath.select1('Item[1]/Y/@value', item)?.value); - const y2 = Number(xpath.select1('Item[last()]/Y/@value', item)?.value); - - const width = Math.round(Math.abs(x2 - x1)); - const height = Math.round(Math.abs(y2 - y1)); - - userAnswer = { - width: width, - height: height, - }; - } - - totalScore = compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); - } - - // [1-8] - else if (type === "shape.color") { - const items = xpath.select(ele, gpdpXmlDoc); - - for (const item of items) { - const color = parseColorToHex(item.value); - userAnswer = color; - } - - const normalizedRA = rightAnswer.toLowerCase?.(); - totalScore = compareAndScore(userAnswer, normalizedRA, point, key, scoringResult); - } - - else if (type === 'layer.blend.opacity') { - const layers = xpath.select(ele, gpdpXmlDoc); - - for (const layer of layers) { - const blendop = xpath.select1('BlendOp/@value', layer).value; - const opacity = xpath.select1('Opacity/@value', layer).value; - - userAnswer = { - BlendOp: blendop, - Opacity: opacity, - } - } - - totalScore = compareAndScore(userAnswer, rightAnswer, point, key, scoringResult); - } - else if (type == "boolean") { - const items = xpath.select(ele, gpdpXmlDoc); - - // xpath 결과값을 반환하는 요소가 없을 경우 - if (!items) { - scoringResult[key] = 0; - console.log("❌ 찾는 요소 없음"); - } - else { - totalScore += point; - scoringResult[key] = point; - console.log("✅ 찾는 요소 존재함"); - } - } - - // 이펙트 효과의 이름과 속성값을 비교 - else if (type == "effects") { - const items = xpath.select(ele, gpdpXmlDoc); - let matched = false; - - // 각 Item 요소별 이름과 속성값을 구하고 정답과 비교 - for (const item of items) { - const name = xpath.select1('Name/@value', item)?.value; - const attr = xpath.select1(`EffectData/${option?.replace(/"/g, '')}/@value`, item)?.value; - - if (name === rightAnswer[0] && attr === rightAnswer[1]) { - totalScore += point; - scoringResult[key] = point; - matched = true; - console.log("✅ 정답 일치:", rightAnswer); - break; - } - } - - if (!matched) { - scoringResult[key] = 0; - console.log("❌ 정답 없음:", rightAnswer); - } - } - - else if (type == "multiValue") { - if (Array.isArray(rightAnswer)) { - const result = ele ? xpath.select(ele, gpdpXmlDoc) : []; - const resultValues = Array.isArray(result) ? result.map(r => (typeof r === 'object' ? r.value : r)) : [result]; - console.log("🚀 ~ getGpdpScore ~ resultValues:", resultValues) - - const groupSize = rightAnswer.length; - const groupedResult = []; - for (let i = 0; i < resultValues.length; i += groupSize) { - groupedResult.push(resultValues.slice(i, i + groupSize)); - } - console.log("🚀 ~ getGpdpScore ~ groupedResult:", groupedResult) - - // 배열 비교 함수 - function arraysEqual(arr1, arr2) { - if (arr1.length !== arr2.length) return false; - return arr1.every((value, index) => value === arr2[index]); - } - - // groupedResult 내부 배열에서 rightAnswer와 일치하는 배열이 있는지 확인 - const isMatch = groupedResult.some(group => arraysEqual(group, rightAnswer)); - - if (isMatch) { - totalScore += point; - scoringResult[key] = point; - console.log("🚀 ~ 정답 포함"); - } else { - scoringResult[key] = 0; - console.log("🚀 ~ 오답"); - } - } - } - - else if (type == "exact") { - let result = xpath.select(ele, gpdpXmlDoc); - if (result.length == 0) { - scoringResult[key] = 0; - console.log('ele not found'); - continue; - } - if (result[0].value === rightAnswer) { - totalScore += point; - scoringResult[key] = point; - } else { - scoringResult[key] = 0; - console.log('ele not matched, ' + result[0].value); - } - } - - - else if (type == "multi") { - try { - const result = xpath.select(ele, gpdpXmlDoc); - let isSame = true; - // console.log(`ele: ${ele}, value: ${value} result: ${result}`); - - if (result.length == 0) { - console.log('result length 0'); - scoringResult[key] = 0; - continue; - } - - result.forEach((v, i) => { - // value[i] 값이 정수형인 경우에는 float로 변환하여 비교 - // 정수형 v값을 float 형으로 변환하고 소수점 3자리까지 버림 - let temp = v.value; - let answer = rightAnswer[i]; - - if (Number.isFinite(rightAnswer[i]) && !Number.isInteger(rightAnswer[i])) { - temp = parseFloat(v.value); - answer = parseFloat(rightAnswer[i]); - // 소수점 3자리까지 버림 - temp = Math.floor(temp * 1000) / 1000; - } - // answer 문자열 중 : 가 포함되어 있다면 각각 분리하고 그 값의 차이를 구함 - if (typeof answer == "string" && answer.indexOf(':') > -1) { - const [answerStart, answerEnd] = answer.split(':').map(Number); - const [tempStart, tempEnd] = temp.split(':').map(Number); - answer = answerEnd - answerStart; - temp = tempEnd - tempStart; - } - - console.log(`temp: ${temp} answer: ${answer}`); - if (answer !== temp) { - console.log(`answer !== temp`); - isSame = false; - } - }); - totalScore += isSame ? point : 0; - scoringResult[key] = isSame ? point : 0; - } catch (e) { - console.log('err :', e); - scoringResult[key] = 0; - } - } - else if (type == "gradient") { - const items = xpath.select(ele, gpdpXmlDoc); - const startColorXpath = scoringData[key].startColor; - const endColorXpath = scoringData[key].endColor; - let matched = false; - - for (const item of items) { - const startColor = parseColorToHex(xpath.select1(startColorXpath, item)?.value); - const endColor = parseColorToHex(xpath.select1(endColorXpath, item)?.value); - - console.log(startColor + ":" + rightAnswer["startColor"], endColor + ":" + rightAnswer["endColor"]); - - if (startColor === rightAnswer["startColor"] && endColor === rightAnswer["endColor"]) { - totalScore += point; - scoringResult[key] = point; - matched = true; - console.log("✅ 정답 일치:", rightAnswer); - break; - } - } - if (!matched) { - scoringResult[key] = 0; - console.log("❌ 정답 없음:", rightAnswer); - } - } - // 그림자 속성이 있는지 여부 파악해서 그림자 속성 별로 점수 1 점씩 부여 - else if (type == "shadow") { - - const result = xpath.select(ele["shadow"], gpdpXmlDoc); - let shadowScore = 0; - if (result.length == 0) { - scoringResult[key] = 0; - console.log('shadow not found'); - continue; - } - + if (width.length !== 0 && width[0].value == rightAnswer["width"]) { shadowScore += 1; - const width = xpath.select(ele["width"], gpdpXmlDoc); - const distance = xpath.select(ele["distance"], gpdpXmlDoc); - const blur = xpath.select(ele["blur"], gpdpXmlDoc); - const angle = xpath.select(ele["angle"], gpdpXmlDoc); - - if (width.length !== 0 && width[0].value == rightAnswer["width"]) { - shadowScore += 1; - console.log('width matched'); - } - if (distance.length !== 0 && distance[0].value == rightAnswer["distance"]) { - shadowScore += 1; - console.log('distance matched'); - } - if (blur.length !== 0 && blur[0].value == rightAnswer["blur"]) { - shadowScore += 1; - console.log('blur matched'); - } - if (angle.length !== 0 && angle[0].value == rightAnswer["angle"]) { - shadowScore += 1; - console.log('angle matched'); - } - totalScore += shadowScore; - scoringResult[key] = shadowScore; + console.log('width matched'); } - else { - const result = xpath.select(ele, gpdpXmlDoc); - const result2 = null; - let isCheck = false; - - if (result.length == 0) { - isCheck = true; - } - if (isCheck && ele2) { - result2 = xpath.select(ele2, gpdpXmlDoc); - - if (result2.length == 0) { - scoringResult[key] = 0; - continue; - } - result = result2; - // console.log(`1st isChecked: ${isCheck}, result: ${result}`) - } - // value와 result[0].value를 비교하여 같으면 점수 point 부여 - // console.log(`${(value === result[0].value)}, ${result.length > 0 && value === result[0].value} `) - // console.log(`2nd isChecked: ${isCheck}, result: ${result}`) - totalScore += result.length > 0 ? point : 0; - scoringResult[key] = result.length > 0 ? point : 0; + if (distance.length !== 0 && distance[0].value == rightAnswer["distance"]) { + shadowScore += 1; + console.log('distance matched'); } + if (blur.length !== 0 && blur[0].value == rightAnswer["blur"]) { + shadowScore += 1; + console.log('blur matched'); + } + if (angle.length !== 0 && angle[0].value == rightAnswer["angle"]) { + shadowScore += 1; + console.log('angle matched'); + } + totalScore += shadowScore; + scoringResult[key] = shadowScore; + } + else { + const result = xpath.select(ele, gpdpXmlDoc); + const result2 = null; + let isCheck = false; + + if (result.length == 0) { + isCheck = true; + } + if (isCheck && ele2) { + result2 = xpath.select(ele2, gpdpXmlDoc); + + if (result2.length == 0) { + scoringResult[key] = 0; + continue; + } + result = result2; + // console.log(`1st isChecked: ${isCheck}, result: ${result}`) + } + // value와 result[0].value를 비교하여 같으면 점수 point 부여 + // console.log(`${(value === result[0].value)}, ${result.length > 0 && value === result[0].value} `) + // console.log(`2nd isChecked: ${isCheck}, result: ${result}`) + totalScore += result.length > 0 ? point : 0; + scoringResult[key] = result.length > 0 ? point : 0; } - scoringResult['총점'] = totalScore; - return scoringResult; +} +scoringResult['총점'] = totalScore; +return scoringResult; } diff --git a/package-lock.json b/package-lock.json index 747de00..0f99bf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "jsonpath": "^1.1.1", + "loadsh": "^0.0.4", "psd": "^3.4.0", "string-similarity": "^4.0.4", "xlsx": "^0.18.5", @@ -209,6 +210,13 @@ "node": ">= 0.8.0" } }, + "node_modules/loadsh": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/loadsh/-/loadsh-0.0.4.tgz", + "integrity": "sha512-U+wLL8InpfRalWrr+0SuhWgGt10M4OyAk6G8xCYo2rwpiHtxZkWiFpjei0vO463ghW8LPCdhqQxXlMy2qicAEw==", + "deprecated": "This is a typosquat on the popular Lodash package. This is not maintained nor is the original Lodash package.", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", diff --git a/package.json b/package.json index ea43b59..bfd640c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "license": "ISC", "dependencies": { "jsonpath": "^1.1.1", + "loadsh": "^0.0.4", "psd": "^3.4.0", "string-similarity": "^4.0.4", "xlsx": "^0.18.5", diff --git a/psdExport_2.js b/psdExport_2.js index d98594c..d4660d8 100644 --- a/psdExport_2.js +++ b/psdExport_2.js @@ -9,8 +9,8 @@ const { DOMParser } = require('xmldom'); const findSimilarString = require('./findSimilarString'); const getGpdpScore = require('./gpdpScoring.js'); const getToday = require('./getToday.js'); -const { userInfo } = require('os'); -const { get } = require('http'); +// const { userInfo } = require('os'); +// const { get } = require('http'); const todayDate = getToday(); const examRound = '2504'; diff --git a/z.xbook b/z.xbook index 9010b61..4745597 100644 --- a/z.xbook +++ b/z.xbook @@ -1 +1 @@ -[{"kind":2,"language":"xpath","value":"//Layer[Name[@value='Tracking']]/Effects/Item/Name/@value"},{"kind":2,"language":"xpath","value":"sum(//CRTrackList[@Name='텍스트' or @Name='비디오2']/CRTrackClip[@ClipIndex=0]/preceding-sibling::CRTrackClip/@Length)"},{"kind":2,"language":"xpath","value":"//CROwneUnit/CRCUnitArr[@Name=\"아름다운 꽃 축제 (Happy Flower Festival)\"]/@Name"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='{layer}']]/Effects/Item[EffectData/{option}]/Name/@value | //Layer[Name[@value='{layer}']]/Effects/Item/EffectData/{option}/@value"},{"kind":2,"language":"xpath","value":"//CRTrackList[@Name='비디오1']/CRTrackClip[@ClipIndex='3']/@Length"},{"kind":2,"language":"xpath","value":"//CRTransFilter[@ClipIndex=count(//CRTrackList[@Name='비디오1']/CRTrackClip[@ClipIndex=count(//CRClip[@Path='이미지3.jpg']/preceding-sibling::CRClip | //CRClip[@Type='11']/CRCUnitArr[@Path='이미지3.jpg']/../preceding-sibling::CRClip)][1]/preceding-sibling::CRTrackClip)][@Type='2']/@*[name()='ID' or name()='Range' or name()='Type']"},{"kind":2,"language":"xpath","value":"//Document/Width/@value | //Document/Height/@value"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='Flower']]/Effects/Item[EffectData/VibranceValue]/(Name/@value | EffectData/VibranceValue/@value)"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='Flower']]/Effects/Item[EffectData/VibranceValue]/Name/@value | //Layer[Name[@value='Flower']]/Effects/Item/EffectData/VibranceValue/@value"}] \ No newline at end of file +[{"kind":2,"language":"xpath","value":"//Layer[Name[@value='Tracking']]/Effects/Item/Name/@value"},{"kind":2,"language":"xpath","value":"sum(//CRTrackList[@Name='텍스트' or @Name='비디오2']/CRTrackClip[@ClipIndex=0]/preceding-sibling::CRTrackClip/@Length)"},{"kind":2,"language":"xpath","value":"//CROwneUnit/CRCUnitArr[@Name=\"아름다운 꽃 축제 (Happy Flower Festival)\"]/@Name"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='{layer}']]/Effects/Item[EffectData/{option}]/Name/@value | //Layer[Name[@value='{layer}']]/Effects/Item/EffectData/{option}/@value"},{"kind":2,"language":"xpath","value":"//CRTrackList[@Name='비디오1']/CRTrackClip[@ClipIndex='3']/@Length"},{"kind":2,"language":"xpath","value":"//CRTransFilter[@ClipIndex=count(//CRTrackList[@Name='비디오1']/CRTrackClip[@ClipIndex=count(//CRClip[@Path='이미지3.jpg']/preceding-sibling::CRClip | //CRClip[@Type='11']/CRCUnitArr[@Path='이미지3.jpg']/../preceding-sibling::CRClip)][1]/preceding-sibling::CRTrackClip)][@Type='2']/@*[name()='ID' or name()='Range' or name()='Type']"},{"kind":2,"language":"xpath","value":"//Layer[MaskOpType/@value='Clipping']"},{"kind":2,"language":"xpath","value":"//Layer//Shape[shape_type/@value='RECTANGLE' and contains(draw_type/@value, 'Outline')]/primary_color/@value"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='Flower']]/Effects/Item[EffectData/VibranceValue]/Name/@value | //Layer[Name[@value='Flower']]/Effects/Item/EffectData/VibranceValue/@value"}] \ No newline at end of file diff --git a/회차별채점자료/2504/excel_채점기준표/DPI_2504B.xlsx b/회차별채점자료/2504/excel_채점기준표/DPI_2504B.xlsx index d17604a04aa2dee87afdb862757c757e010d99ec..b69d3d258162b5368dbdfe0307f278edfe05b35e 100644 GIT binary patch delta 9038 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 AX#fBK delta 9071 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