Parcourir la source

feat: 绘制轨迹功能模块

zhangjie il y a 1 an
Parent
commit
01cdd63635

+ 91 - 0
electron/preload/api.ts

@@ -1,8 +1,18 @@
 import gm from 'gm';
+import axios from 'axios';
+import sizeOf from 'image-size';
 import path from 'node:path';
+import fs from 'node:fs';
 
 import { getImagicPath, getTempPath } from './utils';
 
+import {
+  DrawTrackItem,
+  DrawTrackCircleOption,
+  DrawTrackLineOption,
+  DrawTrackTextOption,
+} from './types';
+
 // macos install gm imagemagick https://github.com/aheckmann/gm/blob/master/README.md
 const gmInst =
   process.platform === 'win32'
@@ -26,8 +36,89 @@ function cropImage(imgPath: string): Promise<string> {
   });
 }
 
+function drawTrack(
+  imgPath: string,
+  drawTrackList: DrawTrackItem[],
+  outpath: string
+): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const gmObj = gmInst(imgPath);
+
+    const defaultColor = '#f53f3f';
+    const defaultFontSize = 14;
+
+    drawTrackList.forEach((track) => {
+      // text
+      if (track.type === 'text') {
+        const { x, y, text, color, fontSize } =
+          track.option as DrawTrackTextOption;
+        gmObj
+          .fill(color || defaultColor)
+          .fontSize(fontSize || defaultFontSize)
+          .drawText(x, y, text);
+        return;
+      }
+      // circle
+      if (track.type === 'circle') {
+        const { x0, y0, x1, y1 } = track.option as DrawTrackCircleOption;
+        gmObj.drawCircle(x0, y0, x1, y1).stroke(defaultColor, 2);
+        return;
+      }
+
+      // line
+      if (track.type === 'line') {
+        const { x0, y0, x1, y1 } = track.option as DrawTrackLineOption;
+        gmObj.drawLine(x0, y0, x1, y1).stroke(defaultColor, 2);
+      }
+    });
+
+    gmObj.write(outpath, (err) => {
+      if (!err) {
+        return resolve(outpath);
+      }
+      return reject(err);
+    });
+  });
+}
+
+async function downloadFile(url: string, outputPath: string) {
+  const writer = fs.createWriteStream(outputPath);
+
+  const response = await axios({
+    url,
+    method: 'GET',
+    responseType: 'stream',
+  });
+
+  response.data.pipe(writer);
+
+  return new Promise((resolve, reject) => {
+    writer.on('finish', resolve);
+    writer.on('error', reject);
+  });
+}
+
+async function downloadImage(url: string, outputFilename: string) {
+  const outputPath = path.join(getTempPath(), outputFilename);
+  await downloadFile(url, outputPath);
+  const size = sizeOf(outputPath);
+
+  return {
+    url: outputPath,
+    width: size.width || 100,
+    height: size.height || 100,
+  };
+}
+
+function joinPath(paths: string[]) {
+  return path.join(...paths);
+}
+
 const commonApi = {
   cropImage,
+  drawTrack,
+  joinPath,
+  downloadImage,
 };
 
 export type CommonApi = typeof commonApi;

+ 25 - 0
electron/preload/types.ts

@@ -0,0 +1,25 @@
+export interface DrawTrackTextOption {
+  x: number;
+  y: number;
+  text: string;
+  color?: string;
+  fontSize?: number;
+}
+
+export interface DrawTrackCircleOption {
+  x0: number;
+  y0: number;
+  x1: number;
+  y1: number;
+}
+export interface DrawTrackLineOption {
+  x0: number;
+  y0: number;
+  x1: number;
+  y1: number;
+}
+
+export interface DrawTrackItem {
+  type: 'text' | 'circle' | 'line';
+  option: DrawTrackTextOption | DrawTrackCircleOption | DrawTrackLineOption;
+}

+ 1 - 0
package.json

@@ -46,6 +46,7 @@
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.5",
     "gm": "^1.25.0",
+    "image-size": "^1.1.1",
     "js-md5": "^0.8.3",
     "lodash": "^4.17.21",
     "mitt": "^3.0.0",

+ 16 - 0
pnpm-lock.yaml

@@ -48,6 +48,7 @@ specifiers:
   eslint-plugin-vue: ^9.20.1
   gm: ^1.25.0
   husky: ^8.0.1
+  image-size: ^1.1.1
   js-md5: ^0.8.3
   less: ^4.1.3
   lint-staged: ^13.0.3
@@ -86,6 +87,7 @@ dependencies:
   crypto-js: 4.2.0
   dayjs: 1.11.11
   gm: 1.25.0
+  image-size: 1.1.1
   js-md5: 0.8.3
   lodash: 4.17.21
   mitt: 3.0.1
@@ -5506,6 +5508,14 @@ packages:
     dev: true
     optional: true
 
+  /image-size/1.1.1:
+    resolution: {integrity: sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==}
+    engines: {node: '>=16.x'}
+    hasBin: true
+    dependencies:
+      queue: 6.0.2
+    dev: false
+
   /imagemin-gifsicle/7.0.0:
     resolution: {integrity: sha512-LaP38xhxAwS3W8PFh4y5iQ6feoTSF+dTAXFRUEYQWYst6Xd+9L/iPk34QGgK/VO/objmIlmq9TStGfVY2IcHIA==}
     engines: {node: '>=10'}
@@ -7417,6 +7427,12 @@ packages:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
     dev: true
 
+  /queue/6.0.2:
+    resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
+    dependencies:
+      inherits: 2.0.4
+    dev: false
+
   /quick-lru/4.0.1:
     resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
     engines: {node: '>=8'}

+ 34 - 0
src/api/task.ts

@@ -0,0 +1,34 @@
+import axios from 'axios';
+import { CardData, Task, StudentObjectiveInfo } from './types/task';
+
+/** 查看单个学生的试卷轨迹 */
+export async function getSingleStudentTaskOfStudentTrack(
+  studentId: string
+): Promise<Task> {
+  return axios.post(
+    '/api/admin/mark/track/getTask',
+    {},
+    { params: { studentId } }
+  );
+}
+/** 查看单个学生的试卷的题卡 */
+export async function getSingleStudentCardData(
+  studentId: string
+): Promise<CardData> {
+  return axios.post(
+    '/api/admin/mark/track/getCard',
+    {},
+    { params: { studentId } }
+  );
+}
+
+/** 获取学生客观题数据 */
+export async function studentObjectiveConfirmData(
+  studentId: string
+): Promise<StudentObjectiveInfo> {
+  return axios.post(
+    '/api/admin/mark/inspected/objective/getTask',
+    {},
+    { params: { studentId } }
+  );
+}

+ 149 - 0
src/api/types/task.ts

@@ -0,0 +1,149 @@
+export interface CardData {
+  id: string;
+  content: string;
+}
+
+interface SplitConfig {
+  /** index of sheets */
+  i: number;
+  /** 覆盖区域的width */
+  w: number;
+  /** 覆盖区域的height */
+  h: number;
+  /** 从哪里开始覆盖 左上角为 (0, 0) */
+  x: number;
+  /** 从哪里开始覆盖 左上角为 (0, 0) */
+  y: number;
+}
+
+export interface Track {
+  /** 大题号 */
+  mainNumber: number;
+  /** 小题号,当前api中只有number // 特殊标记中没有 */
+  subNumber: string;
+  /** 前端使用,暂时用不着,赋0 */
+  number: number;
+  /** 第几张图 */
+  offsetIndex: number;
+  /** 左上角为原点 */
+  offsetX: number;
+  offsetY: number;
+  /** 相对slice的位置比例 */
+  positionX: number;
+  positionY: number;
+  /** 评分数 */
+  score: number;
+  /** 是否此处未作答,未作答时,score默认是-0分 */
+  unanswered: boolean;
+  userId: string;
+  userName: string;
+  // 是否是科组长评卷轨迹
+  headerMarkScore?: boolean;
+  color?: string;
+  isByMultMark?: boolean;
+}
+
+interface Question {
+  /** 分组序号 */
+  groupNumber: number;
+  /** 大题号 */
+  mainNumber: number;
+  /** 小题号 */
+  subNumber: string;
+  /** 分数间隔 */
+  intervalScore: number;
+  /** 默认分数 */
+  defaultScore: number;
+  /** 限制最小分数 */
+  minScore: number;
+  /** 限制最大分数 */
+  maxScore: number;
+  /** 题目名称 */
+  title: string;
+  /** 轨迹列表 */
+  trackList: Array<Track>;
+  /** 得分;null的值时是为打回时可以被评卷修改的;null也是从未评分过的情况,要通过rejected来判断 */
+  score: number | null;
+  /** 未计分 */
+  uncalculate: boolean;
+  /** 选做题分组 */
+  selectiveIndex: number | null;
+  rejected?: boolean;
+  questionName?: string;
+  headerTrack?: Array<Track>;
+}
+
+export interface SpecialTag {
+  /** 第几张图 */
+  offsetIndex: number;
+  /** 左上角为原点(原图的原点),及相对原图的位置比例 */
+  offsetX: number;
+  offsetY: number;
+  /** 相对裁切图的位置比例 */
+  positionX: number;
+  positionY: number;
+  /** 特殊标记的字符串,勾叉 */
+  tagName: string;
+  tagType: 'TEXT' | 'CIRCLE' | 'RIGHT' | 'WRONG' | 'HALF_RIGTH' | 'LINE';
+  // 分组号
+  groupNumber?: number;
+  userId?: number;
+  color?: string;
+  isByMultMark?: boolean;
+}
+
+export interface Task {
+  /** 学生ID */
+  studentId: string;
+  /** 任务编号 */
+  secretNumber: string;
+  /** 学生名称 */
+  studentName: string;
+  /** 学生编号 */
+  studentCode: string;
+  /** 科目名称 */
+  courseName: string;
+  /** 科目编号 */
+  courseCode: string;
+  /** 试卷编号 */
+  paperNumber: string;
+  /** 最高显示优先级,有sliceConfig就用sliceConfig,否则使用sheetConfig */
+  sliceConfig: Array<SplitConfig>;
+  jsonUrl: string;
+  questionList: Array<Question>;
+  specialTagList: Array<SpecialTag>;
+  /** 原图url */
+  sheetUrls: Array<string>;
+  /** 客观分 复核也用到 */
+  objectiveScore: number;
+  /** 评卷总分 */
+  markerScore: number;
+  /** 评卷时间 */
+  markerTime: number;
+}
+
+export type StudentObjectiveInfo = {
+  studentId: string;
+  studentName: string;
+  studentCode: string;
+  campusName: string;
+  courseCode: string;
+  courseName: string;
+  paperNumber: string;
+  objectiveScore: number;
+  subjectiveScore: number;
+  upload: boolean;
+  absent: boolean;
+  paperType: string;
+  sheetUrls: Array<{ index: number; url: string; recogData: string }>;
+  answers: Array<{
+    mainNumber: number;
+    subNumber: string;
+    answer: string;
+    exist: boolean;
+    questionType: string;
+    standardAnswer: string;
+  }>;
+  titles: { [index: number]: string };
+  success: boolean;
+};

+ 17 - 0
src/utils/utils.ts

@@ -371,3 +371,20 @@ export function snakeToHump(name: string): string {
 export function deepCopy<T>(data: T): T {
   return JSON.parse(JSON.stringify(data));
 }
+
+/**
+ * 获取字符串字符长度:中文2字符,英文1字符
+ * @param content 字符
+ * @returns 字符长度
+ */
+export function strGbLen(content: string) {
+  let len = 0;
+  for (let i = 0; i < content.length; i++) {
+    if (content.charCodeAt(i) > 127 || content.charCodeAt(i) === 94) {
+      len += 2;
+    } else {
+      len++;
+    }
+  }
+  return len;
+}

+ 4 - 4
src/views/track/readme.md

@@ -1,9 +1,9 @@
 # 计划
 
-- sqlite3
-- model/db
+- sqlite3 -ok-
+- model/db -ok-
 - api
 - page view set
-- gm-imagemagic
-- task build
+- gm-imagemagic -ok-
+- task build -ok-
 - process manange

+ 505 - 0
src/views/track/useTrack.ts

@@ -0,0 +1,505 @@
+import {
+  getSingleStudentTaskOfStudentTrack,
+  studentObjectiveConfirmData,
+  getSingleStudentCardData,
+} from '@/api/task';
+import { Task, Track, SpecialTag } from '@/api/types/task';
+import { calcSum, maxNum, randomCode, strGbLen } from '@/utils/utils';
+import { DrawTrackItem } from '../../../electron/preload/types';
+
+type AnswerMap = Record<string, { answer: string; isRight: boolean }>;
+
+interface TrackTtemType {
+  url: string;
+  drawTrackList: DrawTrackItem[];
+}
+
+interface CardDataItem {
+  exchange: {
+    answer_area: Array<{
+      main_number: number;
+      sub_number: number | string;
+      area: [number, number, number, number];
+    }>;
+  };
+}
+
+interface CardContentType {
+  pages: CardDataItem[];
+}
+
+interface QuestionItem {
+  mainNumber: number;
+  subNumber: number | string;
+}
+interface QuestionArea {
+  i: number;
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+  qStruct: string;
+}
+
+type UserMapType = Record<
+  string,
+  { userId: string; userName: string; scores: number[] }
+>;
+
+interface ImageItem {
+  url: string;
+  width: number;
+  height: number;
+}
+
+interface PaperRecogData {
+  page_index: number;
+  question: Array<{
+    index: number;
+    fill_result: Array<{
+      main_number: number;
+      sub_number: number;
+      single: number;
+      fill_option: number[];
+      suspect_flag: number;
+      fill_position: string[];
+      fill_size: number[];
+    }>;
+  }>;
+}
+
+export default function useTrack() {
+  let answerMap = {} as AnswerMap;
+  let cardData = [] as CardDataItem[];
+  let recogDatas: string[] = [];
+  let rawTask = {} as Task;
+  let trackData = [] as TrackTtemType[];
+
+  async function runTask(studentId: string) {
+    initData();
+    try {
+      await getTaskData(studentId);
+      await parseDrawList();
+      await drawTask();
+    } catch (error) {
+      console.log(error);
+      return Promise.reject(error);
+    }
+    return true;
+  }
+
+  function initData() {
+    cardData = [] as CardDataItem[];
+    recogDatas = [] as string[];
+    rawTask = {} as Task;
+    trackData = [] as TrackTtemType[];
+    answerMap = {} as AnswerMap;
+  }
+
+  async function getTaskData(studentId: string) {
+    rawTask = await getSingleStudentTaskOfStudentTrack(studentId);
+    if (!rawTask) return;
+
+    // rawTask.sheetUrls = ["/1-1.jpg", "/1-2.jpg"];
+    if (!rawTask.sheetUrls) rawTask.sheetUrls = [];
+
+    // 获取客观题选项信息
+    const objectiveData = await studentObjectiveConfirmData(studentId);
+    objectiveData.answers.forEach((item) => {
+      answerMap[`${item.mainNumber}_${item.subNumber}`] = {
+        answer: item.answer,
+        isRight: item.answer === item.standardAnswer,
+      };
+    });
+    recogDatas = objectiveData.sheetUrls.map((item) => item.recogData);
+
+    // 获取题卡数据
+    const cardRes = await getSingleStudentCardData(studentId);
+    const cardContent = cardRes.content
+      ? (JSON.parse(cardRes.content) as CardContentType)
+      : { pages: [] };
+    cardData = cardContent.pages;
+  }
+
+  async function downloadImages(urls: string[]) {
+    const downloads: Promise<ImageItem>[] = [];
+    for (let i = 0; i < urls.length; i++) {
+      downloads.push(
+        window.api.downloadImage(
+          urls[i],
+          `${rawTask.studentId}-${i}-${randomCode(8)}.jpg`
+        )
+      );
+    }
+
+    const images = await Promise.all(downloads).catch((error) => {
+      console.log(error);
+    });
+    if (!images) {
+      return Promise.reject(new Error('下载图片失败'));
+    }
+    return images;
+  }
+
+  async function parseDrawList() {
+    trackData = [];
+
+    const trackLists = (rawTask.questionList || [])
+      .map((q) => {
+        return q.headerTrack?.length
+          ? addHeaderTrackColorAttr(q.headerTrack)
+          : addTrackColorAttr(q.trackList);
+      })
+      .flat();
+
+    const images = await downloadImages(rawTask.sheetUrls);
+
+    const markDeailList = parseMarkDetailList(images);
+
+    for (let i = 0; i < images.length; i++) {
+      const img = images[i];
+      const drawTrackList = [] as DrawTrackItem[];
+      trackLists
+        .filter((item) => item.offsetIndex === i + 1)
+        .forEach((item) => {
+          drawTrackList.push(getDrawTrackItem(item));
+        });
+      rawTask.specialTagList
+        .filter((item) => item.offsetIndex === i + 1)
+        .forEach((item) => {
+          drawTrackList.push(getDrawTagTrackItem(item));
+        });
+      const answerTags = paserRecogData(i);
+      drawTrackList.push(...answerTags);
+      drawTrackList.push(...markDeailList[i]);
+
+      trackData[i] = {
+        url: img.url,
+        drawTrackList,
+      };
+    }
+  }
+
+  async function drawTask(): Promise<string[]> {
+    if (!trackData.length) return [];
+
+    const tasks: Promise<string>[] = [];
+    for (let i = 0; i < trackData.length; i++) {
+      const item = trackData[i];
+      const outpath = getOutputPath(i + 1);
+      tasks.push(window.api.drawTrack(item.url, item.drawTrackList, outpath));
+    }
+    const res = await Promise.all(tasks).catch((error) => {
+      console.log(error);
+    });
+    if (!res) {
+      return Promise.reject(res);
+    }
+    return res;
+  }
+
+  function getOutputPath(ind: number) {
+    // TODO:
+    const no = `000${ind}`.slice(-3);
+    return window.api.joinPath(['', `${no}.jpg`]);
+  }
+
+  // track ----- start->
+  function getDrawTrackItem(track: Track): DrawTrackItem {
+    return {
+      type: 'text',
+      option: {
+        x: track.offsetX,
+        y: track.offsetY,
+        text: String(track.score),
+        color: track.color,
+      },
+    };
+  }
+
+  function getDrawTagTrackItem(track: SpecialTag): DrawTrackItem {
+    if (track.tagType === 'LINE') {
+      const tagProp = JSON.parse(track.tagName) as {
+        len: number;
+      };
+
+      return {
+        type: 'line',
+        option: {
+          x0: track.offsetX,
+          y0: track.offsetY,
+          x1: track.offsetX + tagProp.len,
+          y1: track.offsetY,
+        },
+      };
+    }
+
+    if (track.tagType === 'CIRCLE') {
+      const tagProp = JSON.parse(track.tagName) as {
+        width: number;
+        height: number;
+      };
+
+      return {
+        type: 'circle',
+        option: {
+          x0: track.offsetX,
+          y0: track.offsetY,
+          x1: track.offsetX + tagProp.width,
+          y1: track.offsetY + tagProp.height,
+        },
+      };
+    }
+
+    return {
+      type: 'text',
+      option: {
+        x: track.offsetX,
+        y: track.offsetY,
+        text: track.tagName,
+        color: track.color,
+      },
+    };
+  }
+
+  function addHeaderTrackColorAttr(headerTrack: Track[]): Track[] {
+    return headerTrack.map((item) => {
+      item.color = 'green';
+      return item;
+    });
+  }
+
+  function addTrackColorAttr(tList: Track[]): Track[] {
+    let markerIds: string[] = tList.map((v) => v.userId).filter((x) => !!x);
+    markerIds = Array.from(new Set(markerIds));
+    // markerIds.sort();
+    const colorMap: Record<string, string> = {};
+    for (let i = 0; i < markerIds.length; i++) {
+      const mId = markerIds[i];
+      if (i === 0) {
+        colorMap[mId] = 'red';
+      } else if (i === 1) {
+        colorMap[mId] = 'blue';
+      } else if (i > 1) {
+        colorMap[mId] = 'gray';
+      }
+    }
+    type ColorK = keyof typeof colorMap;
+    tList = tList.map((item: Track) => {
+      const k = item.userId as ColorK;
+      item.color = colorMap[k] || 'red';
+      item.isByMultMark = markerIds.length > 1;
+      return item;
+    });
+    return tList;
+  }
+
+  /* 
+  function addTagColorAttr(tList: SpecialTag[]): SpecialTag[] {
+    let markerIds: string[] = tList
+      .map((v) => String(v.userId))
+      .filter((x) => !!x);
+    markerIds = Array.from(new Set(markerIds));
+    // markerIds.sort();
+    const colorMap: Record<string, string> = {};
+    for (let i = 0; i < markerIds.length; i++) {
+      const mId = markerIds[i];
+      if (i === 0) {
+        colorMap[mId] = 'red';
+      } else if (i === 1) {
+        colorMap[mId] = 'blue';
+      } else if (i > 1) {
+        colorMap[mId] = 'gray';
+      }
+    }
+    type ColorK = keyof typeof colorMap;
+    tList = tList.map((item: SpecialTag) => {
+      const k = String(item.userId) as ColorK;
+      item.color = colorMap[k] || 'red';
+      item.isByMultMark = markerIds.length > 1;
+      return item;
+    });
+    return tList;
+  }
+  */
+
+  // track ----- end->
+
+  // mark detail ----- start->
+  // 解析各试题答题区域以及评分
+  function parseMarkDetailList(images: ImageItem[]): Array<DrawTrackItem[]> {
+    const dataList: Array<DrawTrackItem[]> = [];
+
+    (rawTask.questionList || []).forEach((question) => {
+      const areas = parseQuestionAreas([question]);
+      if (!areas.length) return;
+      const area = { ...areas[0] };
+      const imgIndex = area.i - 1;
+      if (!dataList[imgIndex]) {
+        dataList[imgIndex] = [];
+      }
+
+      const img = images[imgIndex] as ImageItem;
+      area.x *= img.width;
+      area.y *= img.height;
+      area.w *= img.width;
+
+      const dataArr = dataList[imgIndex];
+      const userMap: UserMapType = {};
+      question.trackList.forEach((track) => {
+        if (!userMap[track.userId]) {
+          userMap[track.userId] = {
+            userId: track.userId,
+            userName: track.userName,
+            scores: [],
+          };
+        }
+        userMap[track.userId].scores.push(track.score);
+      });
+
+      Object.values(userMap).forEach((user, index) => {
+        const userScore = calcSum(user.scores);
+        const content = `评卷员:${user.userName},评分:${userScore}`;
+        dataArr.push({
+          type: 'text',
+          option: {
+            x: area.x,
+            y: area.y + index * 20,
+            text: content,
+          },
+        });
+      });
+      const tCont = `得分:${question.score},满分:${question.maxScore}`;
+      const tContLen = strGbLen(tCont) / 2;
+      dataArr.push({
+        type: 'text',
+        option: {
+          x: area.x + area.w - Math.ceil(tContLen * 20),
+          y: area.y,
+          text: tCont,
+          fontSize: 20,
+        },
+      });
+    });
+
+    return dataList;
+  }
+
+  function parseQuestionAreas(questions: QuestionItem[]) {
+    if (!questions.length || !cardData?.length) return [];
+
+    const pictureConfigs: QuestionArea[] = [];
+    const structs = questions.map(
+      (item) => `${item.mainNumber}_${item.subNumber}`
+    );
+    cardData.forEach((page, pindex) => {
+      page.exchange.answer_area.forEach((area) => {
+        const [x, y, w, h] = area.area;
+        const qStruct = `${area.main_number}_${area.sub_number}`;
+
+        const pConfig: QuestionArea = {
+          i: pindex + 1,
+          x,
+          y,
+          w,
+          h,
+          qStruct,
+        };
+
+        if (typeof area.sub_number === 'number') {
+          if (!structs.includes(qStruct)) return;
+          pictureConfigs.push(pConfig);
+          return;
+        }
+        // 复合区域处理,比如填空题,多个小题合并为一个区域
+        if (typeof area.sub_number === 'string') {
+          const areaStructs = area.sub_number
+            .split(',')
+            .map((subNumber) => `${area.main_number}_${subNumber}`);
+          if (
+            structs.some((struct) => areaStructs.includes(struct)) &&
+            !pictureConfigs.find((item) => item.qStruct === qStruct)
+          ) {
+            pictureConfigs.push(pConfig);
+          }
+        }
+      });
+    });
+    // console.log(pictureConfigs);
+
+    // 合并相邻区域
+    pictureConfigs.sort((a, b) => {
+      return a.i - b.i || a.x - b.x || a.y - b.y;
+    });
+    const combinePictureConfigList: QuestionArea[] = [];
+    let prevConfig = {} as QuestionArea;
+    pictureConfigs.forEach((item, index) => {
+      if (!index) {
+        prevConfig = { ...item };
+        combinePictureConfigList.push(prevConfig);
+        return;
+      }
+
+      const elasticRate = 0.01;
+      if (
+        prevConfig.i === item.i &&
+        prevConfig.y + prevConfig.h + elasticRate >= item.y &&
+        prevConfig.w === item.w &&
+        prevConfig.x === item.x
+      ) {
+        prevConfig.h = item.y + item.h - prevConfig.y;
+      } else {
+        prevConfig = { ...item };
+        combinePictureConfigList.push(prevConfig);
+      }
+    });
+    // console.log(combinePictureConfigList);
+    return combinePictureConfigList;
+  }
+  // mark detail ----- end->
+
+  // answer tag ----- start->
+  // 解析客观题答案展示位置
+  function paserRecogData(imageIndex: number): DrawTrackItem[] {
+    if (!recogDatas.length || !recogDatas[imageIndex]) return [];
+
+    const recogData: PaperRecogData = JSON.parse(
+      window.atob(recogDatas[imageIndex])
+    );
+    const answerTags: DrawTrackItem[] = [];
+    recogData.question.forEach((question) => {
+      question.fill_result.forEach((result) => {
+        const fillPositions = result.fill_position.map((pos) => {
+          return pos.split(',').map((n) => Number(n));
+        });
+
+        const offsetLt = result.fill_size.map((item) => item * 0.4);
+        const tagLeft =
+          maxNum(fillPositions.map((pos) => pos[0])) +
+          result.fill_size[0] -
+          offsetLt[0];
+        const tagTop = fillPositions[0][1] - offsetLt[1];
+
+        const { answer, isRight } =
+          answerMap[`${result.main_number}_${result.sub_number}`] || {};
+
+        answerTags.push({
+          type: 'text',
+          option: {
+            x: tagLeft,
+            y: tagTop,
+            text: answer,
+            color: isRight ? '#05b575' : '#f53f3f',
+          },
+        });
+      });
+    });
+
+    return answerTags;
+  }
+  // answer tag ----- end->
+
+  return {
+    runTask,
+  };
+}