diff --git a/docs/kangcheolung/issue-96-ocr-response-storage.md b/docs/kangcheolung/issue-96-ocr-response-storage.md new file mode 100644 index 0000000..4fb58de --- /dev/null +++ b/docs/kangcheolung/issue-96-ocr-response-storage.md @@ -0,0 +1,151 @@ +# 이슈 #96 — OCR 응답 원본 저장 + 파서 회귀 테스트 기반 + +> 브랜치: `feature/96` · 관련 이슈: [PIUDAProject/Backend#96](https://github.com/PIUDAProject/Backend/issues/96) +> +> OCR 파싱 개선 1단계의 선행 작업. 후속: #B(표 처방전 좌표 파싱), #C(약봉투 다중 약) + +--- + +## 1. 배경 + +약봉투·처방전 OCR 추출이 여러 서식에서 깨지는데, **실패를 재현할 수단이 없었다.** + +| 문제 | 상세 | +|---|---| +| 응답 원본 유실 | `NaverOcrClient`가 응답을 `NaverOcrApiResponse`로 파싱한 뒤 원본을 버림. `OcrResult`엔 텍스트를 이어붙인 `raw_text`만 남고 좌표(`boundingPoly`)는 사라짐 | +| 테스트 0개 | `OcrParser` 단위 테스트가 없어, 서식 하나를 고치면 다른 서식이 회귀했는지 알 수 없음 | +| 개인정보 | `raw_text`에 환자 주민번호가 마스킹 없이 저장됨 | + +이 이슈는 파싱 로직을 고치지 않는다. **회귀 테스트 기반**만 만든다. 응답 DTO(`OcrResultResponse`) +외부 계약은 그대로 두어 프론트 영향이 없다. + +--- + +## 2. 변경 + +### 2-1. Naver 응답 원본 저장 + +`NaverOcrClient.callOcr()` — `bodyToMono(String.class)`로 원문을 받아 주입한 `ObjectMapper`로 파싱. +반환 타입을 `List` → `NaverOcrCallResult(fields, rawResponseJson)`로 변경. + +```text +webClient.post()...bodyToMono(String.class) ← 원문 문자열 + → objectMapper.readValue(raw, NaverOcrApiResponse.class) + → new NaverOcrCallResult(extractFields(response), raw) +``` + +- WebClient 코덱 한도를 256KB(기본) → 10MB로 상향. 표 처방전은 필드(텍스트+좌표 4점)가 수백 개라 + 기본 한도를 넘을 수 있음. `webClient.mutate().codecs(...)`로 이 클라이언트만 조정. +- `extractFields()` 검증(`inferResult != SUCCESS` → `OCR_API_ERROR`)은 그대로. +- 응답 원문을 재직렬화가 아니라 **문자열 그대로** 저장하는 이유: Naver의 `inferConfidence` 등 + 우리 DTO에 없는 필드까지 보존해 회귀 코퍼스의 충실도를 유지. + +`OcrResult` — `raw_response` `MEDIUMTEXT` 컬럼 + 빌더 파라미터. `ddl-auto: update`라 마이그레이션 파일 불필요. +MySQL `TEXT`는 64KB라, 좌표가 붙는 표 처방전 응답(WebClient 한도 10MB)을 담으려면 `MEDIUMTEXT`(16MB)가 필요하다. + +### 2-2. 주민번호 마스킹 + +신규 `global/util/PiiMasker` — 주민번호만 대상. + +```java +// 6자리 [-] 7자리, 뒷자리 첫 숫자 1~8. 하이픈 선택적, 앞뒤 숫자 경계로 부분 일치 방지 +Pattern.compile("(? 현재 `OcrController`·`MedicationController`에 로컬 테스트용 `userId` 하드코딩이 있음. +> **PR 전 `git checkout --`로 제거** (커밋 금지). + +```bash +./gradlew bootRun --args='--spring.profiles.active=local' +``` + +`POST /api/ocr` (form-data: `seniorId=1`, `image=<처방전 사진>`, `ocrType=PRESCRIPTION`) + +- [ ] `ocr_result` 새 행의 `raw_response`에 좌표 포함 응답 JSON 저장 +- [ ] 주민번호 있는 처방전 → `raw_text`·`raw_response` 모두 `******-*******` +- [ ] `OcrResultResponse` 필드 형태 변화 없음 + +--- + +## 6. 후속 + +- **이슈 B**: 표 처방전 좌표 파싱 복구 — 게이트 정규식 `\s*`→`[ \t]*`, `parseByCoordinates` + 헤더 x좌표 앵커 배정, `table_prescription_synth.json` guard 승격 +- **이슈 C**: 약봉투 압축형(`약이름\n1정씩1회5일분` 반복) 다중 약 +- **이슈 D**(범위 밖): ES/DrugInfo로 약 이름 검증 +- fixture 코퍼스 확장: 실제 약봉투·처방전 사진 (이름 마스킹) diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java b/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java index 3941864..aa95fb0 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java @@ -1,18 +1,19 @@ package com.piuda.callcare.domain.ocrresult.client; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.piuda.callcare.domain.ocrresult.dto.NaverOcrCallResult; import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; import com.piuda.callcare.global.exception.CallCareException; import com.piuda.callcare.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.multipart.MultipartFile; import java.time.Duration; import java.util.List; @@ -21,10 +22,13 @@ @Slf4j @Component -@RequiredArgsConstructor public class NaverOcrClient { + // 표 처방전은 필드(텍스트+좌표)가 많아 응답이 기본 코덱 한도(256KB)를 넘을 수 있어 상향 + private static final int MAX_IN_MEMORY_SIZE = 10 * 1024 * 1024; + private final WebClient webClient; + private final ObjectMapper objectMapper; @Value("${naver.ocr.invoke-url}") private String invokeUrl; @@ -32,7 +36,21 @@ public class NaverOcrClient { @Value("${naver.ocr.secret-key}") private String secretKey; - public List callOcr(MultipartFile image) { + public NaverOcrClient(WebClient webClient, ObjectMapper objectMapper) { + this.webClient = webClient.mutate() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_IN_MEMORY_SIZE)) + .build(); + this.objectMapper = objectMapper; + } + + /** + * 이미지를 Naver OCR에 보내 인식 결과를 받는다. + * + * @param image 처방전·약봉투 이미지 + * @return 파싱용 {@code fields}와 저장용 응답 원문 JSON + * @throws com.piuda.callcare.global.exception.CallCareException OCR API 오류·실패({@code OCR_API_ERROR}) + */ + public NaverOcrCallResult callOcr(MultipartFile image) { try { String filename = Objects.requireNonNullElse(image.getOriginalFilename(), "image.jpg"); String format = extractFormat(filename); @@ -45,17 +63,18 @@ public String getFilename() { } }; - NaverOcrApiResponse response = webClient.post() + String rawResponseJson = webClient.post() .uri(invokeUrl) .header("X-OCR-SECRET", secretKey) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData("message", messageJson) .with("file", imageResource)) .retrieve() - .bodyToMono(NaverOcrApiResponse.class) + .bodyToMono(String.class) .block(Duration.ofSeconds(35)); - return extractFields(response); + NaverOcrApiResponse response = objectMapper.readValue(rawResponseJson, NaverOcrApiResponse.class); + return new NaverOcrCallResult(extractFields(response), rawResponseJson); } catch (WebClientResponseException e) { log.error("Naver OCR API 응답 오류 - status: {}, body: {}", e.getStatusCode(), e.getResponseBodyAsString()); diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/dto/NaverOcrCallResult.java b/src/main/java/com/piuda/callcare/domain/ocrresult/dto/NaverOcrCallResult.java new file mode 100644 index 0000000..49596f1 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/dto/NaverOcrCallResult.java @@ -0,0 +1,14 @@ +package com.piuda.callcare.domain.ocrresult.dto; + +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; + +import java.util.List; + +/** + * Naver OCR 호출 결과. 파싱에 쓰는 {@code fields}와, 실패 재현·회귀 테스트용으로 + * 저장할 응답 원문 {@code rawResponseJson}을 함께 담는다. + */ +public record NaverOcrCallResult( + List fields, + String rawResponseJson +) {} diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java b/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java index 14e0424..397350d 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java @@ -36,6 +36,10 @@ public class OcrResult { @Column(name = "raw_text", columnDefinition = "TEXT") private String rawText; // OCR로 추출된 원본 텍스트 + // MEDIUMTEXT(16MB): 표 처방전 응답은 토큰마다 좌표가 붙어 TEXT(64KB)를 넘길 수 있음 + @Column(name = "raw_response", columnDefinition = "MEDIUMTEXT") + private String rawResponse; // Naver OCR 응답 원문 JSON (좌표 포함, 실패 재현·회귀 테스트용) + @Column(name = "parsed_drug_name") private String parsedDrugName; // OCR 결과에서 추출된 약 이름 @@ -55,11 +59,12 @@ public class OcrResult { private LocalDateTime createdAt; @Builder - public OcrResult(Senior senior, String imageUrl, OcrType ocrType, String rawText) { + public OcrResult(Senior senior, String imageUrl, OcrType ocrType, String rawText, String rawResponse) { this.senior = senior; this.imageUrl = imageUrl; this.ocrType = ocrType; this.rawText = rawText; + this.rawResponse = rawResponse; this.isProcessed = false; this.createdAt = LocalDateTime.now(); } diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java index 06b3ca0..5cf1b9f 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java @@ -2,9 +2,9 @@ import com.piuda.callcare.domain.ocrresult.client.NaverOcrClient; import com.piuda.callcare.domain.ocrresult.converter.OcrResultConverter; +import com.piuda.callcare.domain.ocrresult.dto.NaverOcrCallResult; import com.piuda.callcare.domain.ocrresult.dto.OcrParseResult; import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData; -import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; import com.piuda.callcare.domain.ocrresult.dto.response.OcrResultResponse; import com.piuda.callcare.domain.ocrresult.entity.OcrResult; import com.piuda.callcare.domain.ocrresult.enums.OcrType; @@ -14,13 +14,12 @@ import com.piuda.callcare.domain.senior.repository.SeniorRepository; import com.piuda.callcare.global.exception.CallCareException; import com.piuda.callcare.global.exception.ErrorCode; +import com.piuda.callcare.global.util.PiiMasker; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.util.List; - @Slf4j @Service @RequiredArgsConstructor @@ -36,17 +35,18 @@ public OcrResultResponse processOcr(Long userId, Long seniorId, MultipartFile im Senior senior = seniorRepository.findByIdAndUser_Id(seniorId, userId) .orElseThrow(() -> new CallCareException(ErrorCode.SENIOR_NOT_FOUND)); - // OCR 호출 → fields(텍스트 + 좌표 블록 목록) 반환 - List fields = naverOcrClient.callOcr(image); + // OCR 호출 → fields(텍스트 + 좌표 블록 목록) + 응답 원문 반환 + NaverOcrCallResult ocrCallResult = naverOcrClient.callOcr(image); // 파싱: rawText 조립 + 약 정보 추출 (표 처방전이면 여러 약) - OcrParseResult parseResult = ocrParser.parse(fields, ocrType); + OcrParseResult parseResult = ocrParser.parse(ocrCallResult.fields(), ocrType); - // OcrResult DB 저장: rawText + 첫 번째 약 파싱 결과 + // OcrResult DB 저장: rawText + 응답 원문 + 첫 번째 약 파싱 결과. 주민번호는 저장 전 마스킹 OcrResult ocrResult = OcrResult.builder() .senior(senior) .ocrType(ocrType) - .rawText(parseResult.rawText()) + .rawText(PiiMasker.maskResidentNumber(parseResult.rawText())) + .rawResponse(PiiMasker.maskResidentNumber(ocrCallResult.rawResponseJson())) .build(); ParsedOcrData first = parseResult.parsedDrugs().isEmpty() diff --git a/src/main/java/com/piuda/callcare/global/util/PiiMasker.java b/src/main/java/com/piuda/callcare/global/util/PiiMasker.java new file mode 100644 index 0000000..38799f0 --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/util/PiiMasker.java @@ -0,0 +1,34 @@ +package com.piuda.callcare.global.util; + +import java.util.regex.Pattern; + +/** + * OCR 결과를 저장하기 전 민감정보를 가리는 유틸. + *

+ * 주민등록번호만 대상으로 한다. 형식이 고정("6자리 [-] 7자리", 뒷자리 첫 숫자 1~8)이라 + * 정규식으로 안전하게 잡힌다. 하이픈은 OCR이 놓치는 경우가 있어 선택적으로 두고, + * 앞뒤 숫자 경계(lookbehind/lookahead)로 더 긴 숫자열 내부 부분 일치를 막는다. + * 환자 이름·생년월일은 형식이 없어 자동 식별이 어렵고, 저장 허용 범위라 건드리지 않는다. + */ +public final class PiiMasker { + + private static final Pattern RESIDENT_NUMBER = + Pattern.compile("(? loadFields(String fixtureFile) { + try (InputStream in = open(FIXTURE_DIR + fixtureFile)) { + NaverOcrApiResponse response = MAPPER.readValue(in, NaverOcrApiResponse.class); + return response.images().get(0).fields(); + } catch (Exception e) { + throw new IllegalStateException("fixture 로드 실패: " + fixtureFile, e); + } + } + + /** fixture 파일명 → 기대 약 목록 매핑(manifest.json)을 읽는다. */ + public static Manifest loadManifest() { + try (InputStream in = open(MANIFEST_PATH)) { + return MAPPER.readValue(in, Manifest.class); + } catch (Exception e) { + throw new IllegalStateException("manifest 로드 실패", e); + } + } + + private static InputStream open(String path) { + InputStream in = OcrFixtureLoader.class.getResourceAsStream(path); + if (in == null) { + throw new IllegalStateException("리소스 없음: " + path); + } + return in; + } + + public record Manifest(List fixtures) { + } + + public record FixtureCase( + String file, + String ocrType, + boolean guard, + String note, + List expected + ) { + } +} diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java new file mode 100644 index 0000000..ce2d89d --- /dev/null +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java @@ -0,0 +1,94 @@ +package com.piuda.callcare.domain.ocrresult.service; + +import com.piuda.callcare.domain.ocrresult.dto.OcrParseResult; +import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData; +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; +import com.piuda.callcare.domain.ocrresult.enums.OcrType; +import com.piuda.callcare.domain.ocrresult.fixture.OcrFixtureLoader; +import com.piuda.callcare.domain.ocrresult.fixture.OcrFixtureLoader.FixtureCase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 저장된 실제/합성 OCR 응답을 {@link OcrParser}에 돌려 회귀를 잡는다. + *

    + *
  • guard=true fixture: 현재 정상 동작 → 결과가 어긋나면 실패 (회귀 가드)
  • + *
  • guard=false fixture: 아직 미달 → precision/recall만 리포트, 실패시키지 않음 + * (해당 이슈에서 fix + guard 승격)
  • + *
+ */ +@DisplayName("OcrParser 회귀 테스트") +class OcrParserRegressionTest { + + private final OcrParser ocrParser = new OcrParser(); + + @TestFactory + @DisplayName("fixture별 precision/recall 리포트 + 회귀 가드") + Stream 회귀_리포트() { + List cases = OcrFixtureLoader.loadManifest().fixtures(); + return cases.stream().map(fc -> DynamicTest.dynamicTest(fc.file(), () -> { + List fields = OcrFixtureLoader.loadFields(fc.file()); + OcrParseResult result = ocrParser.parse(fields, OcrType.valueOf(fc.ocrType())); + List actual = result.parsedDrugs(); + + Score score = score(fc.expected(), actual); + System.out.printf( + "[%s] exact P=%.2f R=%.2f | name R=%.2f | 기대 %d, 추출 %d, 정확일치 %d%n ↳ %s%n", + fc.file(), score.precision(), score.recall(), score.nameRecall(), + fc.expected().size(), actual.size(), score.exactMatches(), fc.note()); + + if (fc.guard()) { + assertThat(actual) + .as("guard fixture는 기대 약 목록과 정확히 일치해야 한다: %s", fc.file()) + .containsExactlyInAnyOrderElementsOf(fc.expected()); + } + })); + } + + @Test + @DisplayName("정상 케이스: 별표형 영수증 - 약 4건, 병원명 '튼튼정'은 약으로 잡히지 않는다") + void 별표형_영수증_약4건_병원명오탐없음() { + // Given + List fields = OcrFixtureLoader.loadFields("pharmacy_receipt_starred.json"); + + // When + List drugs = ocrParser.parse(fields, OcrType.PRESCRIPTION).parsedDrugs(); + + // Then + assertThat(drugs).hasSize(4); + assertThat(drugs).extracting(ParsedOcrData::drugName) + .containsExactly("아클펜정", "아트놀셋세미정", "모사피트정", "에페신정") + .doesNotContain("튼튼정"); + assertThat(drugs).allSatisfy(d -> { + assertThat(d.dosagePerTime()).isEqualTo("1정"); + assertThat(d.timesPerDay()).isEqualTo(2); + assertThat(d.totalDays()).isEqualTo(5); + }); + } + + // 기대 약 목록 대비 파싱 결과 채점. exact = 4개 필드 완전 일치, name = 약 이름만 일치 + private Score score(List expected, List actual) { + long exact = expected.stream().filter(actual::contains).count(); + long nameHit = expected.stream() + .filter(e -> actual.stream().anyMatch(a -> equalsIgnoreNull(e.drugName(), a.drugName()))) + .count(); + double precision = actual.isEmpty() ? 0.0 : (double) exact / actual.size(); + double recall = expected.isEmpty() ? 0.0 : (double) exact / expected.size(); + double nameRecall = expected.isEmpty() ? 0.0 : (double) nameHit / expected.size(); + return new Score(precision, recall, nameRecall, exact); + } + + private boolean equalsIgnoreNull(String a, String b) { + return a != null && a.equals(b); + } + + private record Score(double precision, double recall, double nameRecall, long exactMatches) { + } +} diff --git a/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java b/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java new file mode 100644 index 0000000..c4aad4f --- /dev/null +++ b/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java @@ -0,0 +1,64 @@ +package com.piuda.callcare.global.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("PiiMasker 단위 테스트") +class PiiMaskerTest { + + @ParameterizedTest + @ValueSource(strings = { + "환자 900101-1234567 님", + "주민등록번호: 900101 - 1234567", + "OCR가 하이픈을 놓친 경우 9001011234567" + }) + @DisplayName("주민번호(6자리 [-] 7자리)는 하이픈 유무와 무관하게 마스킹된다") + void 주민번호_마스킹(String input) { + String masked = PiiMasker.maskResidentNumber(input); + + assertThat(masked).contains("******-*******"); + assertThat(masked).doesNotContain("1234567"); + } + + @Test + @DisplayName("주민번호만 마스킹하고 교부번호·금액 등 다른 숫자는 건드리지 않는다") + void 다른_숫자는_유지() { + String input = "교부번호 20260701-00042 / 환자 900101-1234567 / 금액 14,940원"; + + String masked = PiiMasker.maskResidentNumber(input); + + assertThat(masked) + .contains("20260701-00042") // 8자리-5자리 → 패턴 불일치, 유지 + .contains("14,940원") + .contains("******-*******") + .doesNotContain("900101-1234567"); + } + + @Test + @DisplayName("뒷자리 첫 숫자가 1~8이 아니면 마스킹하지 않는다") + void 잘못된_뒷자리_미마스킹() { + String input = "코드 123456-9876543"; + + assertThat(PiiMasker.maskResidentNumber(input)).isEqualTo(input); + } + + @ParameterizedTest + @ValueSource(strings = { + "코드 900101-12345678", // 뒷자리 8개 → 주민번호 아님 + "토큰 A1234567-1234567" // 앞이 주민번호 형식이 아님 + }) + @DisplayName("더 긴 숫자열 내부는 부분 마스킹하지 않는다 (원본 보존)") + void 부분_일치_방지(String input) { + assertThat(PiiMasker.maskResidentNumber(input)).isEqualTo(input); + } + + @Test + @DisplayName("null은 null을 반환한다") + void null_처리() { + assertThat(PiiMasker.maskResidentNumber(null)).isNull(); + } +} diff --git a/src/test/resources/ocr/expected/manifest.json b/src/test/resources/ocr/expected/manifest.json new file mode 100644 index 0000000..96e8247 --- /dev/null +++ b/src/test/resources/ocr/expected/manifest.json @@ -0,0 +1,29 @@ +{ + "fixtures": [ + { + "file": "pharmacy_receipt_starred.json", + "ocrType": "PRESCRIPTION", + "guard": true, + "note": "약제비 영수증 (별표형 약 이름, 좌표 없음, 개인정보 익명화) - 현재 정상 동작, 회귀 가드", + "expected": [ + { "drugName": "아클펜정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, + { "drugName": "아트놀셋세미정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, + { "drugName": "모사피트정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, + { "drugName": "에페신정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 } + ] + }, + { + "file": "table_prescription_synth.json", + "ocrType": "PRESCRIPTION", + "guard": false, + "note": "합성 표 처방전 (좌표 포함) - 이슈 B 대상, 현재 미달", + "expected": [ + { "drugName": "아모잘탄정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 30 }, + { "drugName": "크레스토정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 30 }, + { "drugName": "리피토정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 90 }, + { "drugName": "노바스크정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 28 }, + { "drugName": "아스피린프로텍트정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 30 } + ] + } + ] +} diff --git a/src/test/resources/ocr/fixtures/pharmacy_receipt_starred.json b/src/test/resources/ocr/fixtures/pharmacy_receipt_starred.json new file mode 100644 index 0000000..d9b441b --- /dev/null +++ b/src/test/resources/ocr/fixtures/pharmacy_receipt_starred.json @@ -0,0 +1,707 @@ +{ + "version": "V2", + "requestId": "fixture-starred-receipt", + "timestamp": 0, + "images": [ + { + "uid": "u1", + "name": "image", + "inferResult": "SUCCESS", + "fields": [ + { + "inferText": "약제비 계산서 영수증 [별지제 11호 서식]", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조제약&복약안내", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조제약사: 김조제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조제일자: 2025-01-02", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "환 자 성 명: 홍길동(만 65세/남)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "처방전교부번호: 20250101-00001", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "처방전발행기관: 튼튼정형외과의원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "영 수 증 번 호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "(연월- 일련번호)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "환 자 성 명", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "투 약 일 수", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조 제 일 자.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "2025-01-02", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "20250101-0001", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "홍길동", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "약품명", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "약품사진", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "복약안내 (투약량/횟수/일수)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "복약만료일 2025", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "야간(공휴일)조제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "야간( )공휴일( )", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "*아클펜정(아세클로페낙)_(..", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "약제비총액(①+②+③)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "14,940 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "흰색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "*아트놀셋세미정_(1정)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "튀김강력기", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "나타날 수 있어요", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량 1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "하는 약", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "소염진통제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "위장장애가 나타날 수 있어요. 증상이 심하면 전문가와 상의하세요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "본인부담금①", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "보험자부담금②", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "비급여및전액본인부담금③", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "10,540 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "4,400원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "0 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "0 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "노랑색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "따르한 물과", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "Result", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "추가의 수 작", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "로 진 또는 타입한 시", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "진통제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "충분한 물과 함께 투여하세요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "카 드", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현금영수증", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "0 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총수납금액", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "(①+③)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현 금", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "4,400 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "*모사피트정5밀리그램(모사프..", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량 1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "흰색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "매콤한 서울특별시", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "DAIL 048", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "먹는 약", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "위장운동촉진제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "식사와 관계없이 투여해도 괜찮아요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현금영수증", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "합 계", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "신분확인번호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "4,400 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "[ ]", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "에페신정(에페리손염산염)(..", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "흰색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "순한보는하르는 수정조각 주차지", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량 1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "F29", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "근이완제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "졸음이 올 수 있으므로 운전, 위험한 기계조작시 주의하세요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현금승인번호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "사업자등록번호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "000-00-00000", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "사업장소재지", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "서울특별시 종로구 세종대로", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1, 1호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "상 호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "행복약국", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "성 명", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "이약사", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "발 행 일", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "2025-01-02", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/ocr/fixtures/table_prescription_synth.json b/src/test/resources/ocr/fixtures/table_prescription_synth.json new file mode 100644 index 0000000..6b8ec11 --- /dev/null +++ b/src/test/resources/ocr/fixtures/table_prescription_synth.json @@ -0,0 +1,806 @@ +{ + "version": "V2", + "requestId": "fixture-table-synth", + "timestamp": 0, + "images": [ + { + "uid": "u1", + "name": "image", + "inferResult": "SUCCESS", + "fields": [ + { + "inferText": "처방전", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 90.0, + "y": 19.0 + }, + { + "x": 150.0, + "y": 19.0 + }, + { + "x": 150.0, + "y": 41.0 + }, + { + "x": 90.0, + "y": 41.0 + } + ] + } + }, + { + "inferText": "교부번호 20260701-00042", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 250.0, + "y": 19.0 + }, + { + "x": 550.0, + "y": 19.0 + }, + { + "x": 550.0, + "y": 41.0 + }, + { + "x": 250.0, + "y": 41.0 + } + ] + } + }, + { + "inferText": "처방 의약품의", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 55.0, + "y": 79.0 + }, + { + "x": 205.0, + "y": 79.0 + }, + { + "x": 205.0, + "y": 101.0 + }, + { + "x": 55.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "명칭", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 220.0, + "y": 79.0 + }, + { + "x": 280.0, + "y": 79.0 + }, + { + "x": 280.0, + "y": 101.0 + }, + { + "x": 220.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "1회 투약량", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 375.0, + "y": 79.0 + }, + { + "x": 485.0, + "y": 79.0 + }, + { + "x": 485.0, + "y": 101.0 + }, + { + "x": 375.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "1일 투여횟수", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 515.0, + "y": 79.0 + }, + { + "x": 645.0, + "y": 79.0 + }, + { + "x": 645.0, + "y": 101.0 + }, + { + "x": 515.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "총 투약일수", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 660.0, + "y": 79.0 + }, + { + "x": 780.0, + "y": 79.0 + }, + { + "x": 780.0, + "y": 101.0 + }, + { + "x": 660.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "용법", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 820.0, + "y": 79.0 + }, + { + "x": 880.0, + "y": 79.0 + }, + { + "x": 880.0, + "y": 101.0 + }, + { + "x": 820.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "[급여][643501510]아모잘탄정5/50밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 129.0 + }, + { + "x": 350.0, + "y": 129.0 + }, + { + "x": 350.0, + "y": 151.0 + }, + { + "x": 10.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 129.0 + }, + { + "x": 445.0, + "y": 129.0 + }, + { + "x": 445.0, + "y": 151.0 + }, + { + "x": 415.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 129.0 + }, + { + "x": 595.0, + "y": 129.0 + }, + { + "x": 595.0, + "y": 151.0 + }, + { + "x": 565.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "30", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 129.0 + }, + { + "x": 740.0, + "y": 129.0 + }, + { + "x": 740.0, + "y": 151.0 + }, + { + "x": 700.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 129.0 + }, + { + "x": 960.0, + "y": 129.0 + }, + { + "x": 960.0, + "y": 151.0 + }, + { + "x": 760.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "[급여][123456780]크레스토정10밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 174.0 + }, + { + "x": 350.0, + "y": 174.0 + }, + { + "x": 350.0, + "y": 196.0 + }, + { + "x": 10.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 174.0 + }, + { + "x": 445.0, + "y": 174.0 + }, + { + "x": 445.0, + "y": 196.0 + }, + { + "x": 415.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 174.0 + }, + { + "x": 595.0, + "y": 174.0 + }, + { + "x": 595.0, + "y": 196.0 + }, + { + "x": 565.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "30", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 174.0 + }, + { + "x": 740.0, + "y": 174.0 + }, + { + "x": 740.0, + "y": 196.0 + }, + { + "x": 700.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 174.0 + }, + { + "x": 960.0, + "y": 174.0 + }, + { + "x": 960.0, + "y": 196.0 + }, + { + "x": 760.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "[급여][222333440]리피토정20밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 219.0 + }, + { + "x": 350.0, + "y": 219.0 + }, + { + "x": 350.0, + "y": 241.0 + }, + { + "x": 10.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 219.0 + }, + { + "x": 445.0, + "y": 219.0 + }, + { + "x": 445.0, + "y": 241.0 + }, + { + "x": 415.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 219.0 + }, + { + "x": 595.0, + "y": 219.0 + }, + { + "x": 595.0, + "y": 241.0 + }, + { + "x": 565.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "90", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 219.0 + }, + { + "x": 740.0, + "y": 219.0 + }, + { + "x": 740.0, + "y": 241.0 + }, + { + "x": 700.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 219.0 + }, + { + "x": 960.0, + "y": 219.0 + }, + { + "x": 960.0, + "y": 241.0 + }, + { + "x": 760.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "[급여][555666770]노바스크정5밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 264.0 + }, + { + "x": 350.0, + "y": 264.0 + }, + { + "x": 350.0, + "y": 286.0 + }, + { + "x": 10.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 264.0 + }, + { + "x": 445.0, + "y": 264.0 + }, + { + "x": 445.0, + "y": 286.0 + }, + { + "x": 415.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "2", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 264.0 + }, + { + "x": 595.0, + "y": 264.0 + }, + { + "x": 595.0, + "y": 286.0 + }, + { + "x": 565.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "28", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 264.0 + }, + { + "x": 740.0, + "y": 264.0 + }, + { + "x": 740.0, + "y": 286.0 + }, + { + "x": 700.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 264.0 + }, + { + "x": 960.0, + "y": 264.0 + }, + { + "x": 960.0, + "y": 286.0 + }, + { + "x": 760.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "[급여][888999000]아스피린프로텍트정100밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 309.0 + }, + { + "x": 350.0, + "y": 309.0 + }, + { + "x": 350.0, + "y": 331.0 + }, + { + "x": 10.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 309.0 + }, + { + "x": 445.0, + "y": 309.0 + }, + { + "x": 445.0, + "y": 331.0 + }, + { + "x": 415.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 309.0 + }, + { + "x": 595.0, + "y": 309.0 + }, + { + "x": 595.0, + "y": 331.0 + }, + { + "x": 565.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "30", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 309.0 + }, + { + "x": 740.0, + "y": 309.0 + }, + { + "x": 740.0, + "y": 331.0 + }, + { + "x": 700.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 309.0 + }, + { + "x": 960.0, + "y": 309.0 + }, + { + "x": 960.0, + "y": 331.0 + }, + { + "x": 760.0, + "y": 331.0 + } + ] + } + } + ] + } + ] +} \ No newline at end of file