diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java
index a9d1722b..b17f983e 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java
@@ -21,6 +21,7 @@
* PDFBox로 텍스트 PDF를 Page별 Segment로 변환한다.
*
*
암호화 PDF와 검색 가능한 Text가 없는 PDF를 일반 손상 문서와 구분하며,
+ * 폰트 인코딩이 깨져 추출 Text에 대체 문자(�)가 과도하게 섞인 문서를 차단한다.
* 이미지 OCR과 Chunk 계산은 담당하지 않는다.
*/
@Slf4j
@@ -28,6 +29,8 @@
public class PdfDocumentParser implements DocumentContentParser {
private static final Set SUPPORTED_TYPES = Set.of(DocumentType.PDF);
+ private static final char REPLACEMENT_CHARACTER = '�';
+ private static final double GARBLED_RATIO_THRESHOLD = 0.05;
@Override
public Set supportedTypes() {
@@ -69,6 +72,10 @@ public ParsedDocument parseDocument(byte[] content) {
if (segments.isEmpty()) {
throw new DocGridException(ErrorCode.DOCUMENT_OCR_REQUIRED);
}
+ // 4. 폰트 인코딩이 깨져 대체 문자(�)가 임계치를 넘으면 Chunk·Embedding 이전에 차단한다.
+ if (isGarbled(segments)) {
+ throw new DocGridException(ErrorCode.DOCUMENT_CONTENT_GARBLED);
+ }
return new ParsedDocument(segments);
} catch (InvalidPasswordException exception) {
throw new DocGridException(ErrorCode.DOCUMENT_PDF_ENCRYPTED, exception);
@@ -83,4 +90,15 @@ public ParsedDocument parseDocument(byte[] content) {
private String canonicalize(String text) {
return text.replace("\r\n", "\n").replace('\r', '\n').strip();
}
+
+ static boolean isGarbled(List segments) {
+ long totalLength = 0;
+ long replacementCount = 0;
+ for (ParsedDocumentSegment segment : segments) {
+ String text = segment.text();
+ totalLength += text.length();
+ replacementCount += text.chars().filter(c -> c == REPLACEMENT_CHARACTER).count();
+ }
+ return totalLength > 0 && (double) replacementCount / totalLength > GARBLED_RATIO_THRESHOLD;
+ }
}
diff --git a/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java b/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java
index ebaddc20..f39f94af 100644
--- a/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java
+++ b/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java
@@ -34,7 +34,8 @@ public class WorkerIndexingFailureClassifier {
ErrorCode.DOCUMENT_TEXT_DECODING_FAILED,
ErrorCode.DOCUMENT_PDF_ENCRYPTED,
ErrorCode.DOCUMENT_OCR_REQUIRED,
- ErrorCode.DOCUMENT_PARSING_FAILED
+ ErrorCode.DOCUMENT_PARSING_FAILED,
+ ErrorCode.DOCUMENT_CONTENT_GARBLED
);
private static final Set EMBEDDING_RESULT_ERRORS = EnumSet.of(
ErrorCode.EMBEDDING_DIMENSION_MISMATCH,
diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java
index d66c190f..bfcc612e 100644
--- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java
+++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java
@@ -137,6 +137,11 @@ public enum ErrorCode {
"DOCUMENT-PARSING-007",
"문서 내용을 읽을 수 없습니다."
),
+ DOCUMENT_CONTENT_GARBLED(
+ HttpStatus.UNPROCESSABLE_ENTITY,
+ "DOCUMENT-PARSING-008",
+ "문서 텍스트에 깨진 문자가 많아 처리할 수 없습니다."
+ ),
DOCUMENT_CHUNKS_INCONSISTENT(
HttpStatus.INTERNAL_SERVER_ERROR,
"DOCUMENT-CHUNK-001",
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java
index 77f8464c..6e288e2b 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java
@@ -5,6 +5,7 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -80,6 +81,42 @@ void parseDocument_throwsWhenPdfIsCorrupted() {
assertError(new byte[] {1, 2, 3}, ErrorCode.DOCUMENT_PARSING_FAILED);
}
+ @Test
+ @DisplayName("정상 Text로만 이루어진 PDF는 깨진 문자 검증에 걸리지 않는다")
+ void parseDocument_doesNotFlagCleanTextAsGarbled() throws IOException {
+ byte[] pdf = pdfWithPages("first page", "second page");
+
+ ParsedDocument result = parser.parseDocument(pdf);
+
+ assertThat(result.segments()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("깨진 문자 비율이 임계치 이하면 정상으로 판단한다")
+ void isGarbled_returnsFalse_whenRatioIsAtOrBelowThreshold() {
+ ParsedDocumentSegment segment = new ParsedDocumentSegment(
+ "�" + "a".repeat(19), 1, null, null
+ );
+
+ assertThat(PdfDocumentParser.isGarbled(List.of(segment))).isFalse();
+ }
+
+ @Test
+ @DisplayName("깨진 문자 비율이 임계치를 넘으면 손상으로 판단한다")
+ void isGarbled_returnsTrue_whenRatioExceedsThreshold() {
+ ParsedDocumentSegment segment = new ParsedDocumentSegment(
+ "�".repeat(2) + "a".repeat(19), 1, null, null
+ );
+
+ assertThat(PdfDocumentParser.isGarbled(List.of(segment))).isTrue();
+ }
+
+ @Test
+ @DisplayName("Segment가 없으면 손상으로 판단하지 않는다")
+ void isGarbled_returnsFalse_whenSegmentsAreEmpty() {
+ assertThat(PdfDocumentParser.isGarbled(List.of())).isFalse();
+ }
+
private byte[] pdfWithPages(String... pageTexts) throws IOException {
try (PDDocument document = new PDDocument()) {
for (String pageText : pageTexts) {
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java b/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java
index 2730ca95..f42e4204 100644
--- a/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java
+++ b/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java
@@ -73,6 +73,8 @@ void classify_mapsNonRetryableFailures() {
.failureType()).isEqualTo(IndexingFailureType.DOCUMENT_CONTENT_INVALID);
assertThat(classifier.classify(new DocGridException(ErrorCode.DOCUMENT_PARSING_FAILED))
.failureType()).isEqualTo(IndexingFailureType.DOCUMENT_CONTENT_INVALID);
+ assertThat(classifier.classify(new DocGridException(ErrorCode.DOCUMENT_CONTENT_GARBLED))
+ .failureType()).isEqualTo(IndexingFailureType.DOCUMENT_CONTENT_INVALID);
assertThat(classifier.classify(new DocGridException(ErrorCode.EMBEDDING_VECTOR_INVALID))
.failureType()).isEqualTo(IndexingFailureType.EMBEDDING_RESULT_INVALID);
assertThat(classifier.classify(new DocGridException(ErrorCode.DOCUMENT_CHUNKS_INCONSISTENT))
diff --git a/docs/design/Gimini-3-#257-pdf-garbled-text-validation.md b/docs/design/Gimini-3-#257-pdf-garbled-text-validation.md
new file mode 100644
index 00000000..3efc13e2
--- /dev/null
+++ b/docs/design/Gimini-3-#257-pdf-garbled-text-validation.md
@@ -0,0 +1,47 @@
+# #257 PDF 텍스트 추출 시 깨진 문자(�) 검증 누락 수정
+
+closes #257
+
+---
+
+## 배경
+
+`PdfDocumentParser`는 암호화·빈 문서·OCR 필요·파싱 실패 4가지만 구분한다. 텍스트가 추출은 되지만 PDF 폰트의 ToUnicode CMap이 깨져 대체 문자(U+FFFD, `�`)가 섞여 나오는 경우는 어떤 단계에서도 걸러내지 않았다. 이런 손상 텍스트는 검증 없이 그대로 Chunk → Embedding → RAG 검색까지 전달된다.
+
+에러코드·테스트를 직접 확인해 갭을 검증했다 — `DOCUMENT_CONTENT_INVALID` 류 에러코드도, `�` 비율 검사 로직도 코드베이스 어디에도 없었다.
+
+## 설계 결정
+
+- **검사 단위는 문서 전체 통합.** 페이지별로 개별 차단하면 일부 페이지만 약간 깨진 정상 문서를 과도하게 차단할 수 있어, 모든 Segment의 Text를 합쳐 문서 전체 대비 `�` 비율로 판단한다.
+- **임계치 5%는 코드 상수로 하드코딩.** 이번 최소 구현 범위에서는 `application.yml` 설정으로 노출하지 않는다. 운영 중 오탐/미탐 사례가 쌓이면 조정한다.
+- **비율 계산은 `PdfDocumentParser` 내부 package-private static 메서드(`isGarbled`)로 분리.** PDFBox로 실제 손상 PDF를 인위적으로 재현하기 어려워(아래 테스트 항목 참고), 문자열을 직접 넣어 검증 로직만 단위 테스트할 수 있게 했다.
+- **신규 `ErrorCode.DOCUMENT_CONTENT_GARBLED` (DOCUMENT-PARSING-008, 422)** — 기존 `DOCUMENT_PDF_ENCRYPTED`/`DOCUMENT_OCR_REQUIRED`/`DOCUMENT_PARSING_FAILED`와 동일하게 `WorkerIndexingFailureClassifier`의 `DOCUMENT_CONTENT_ERRORS` 집합에 등록해 `IndexingFailureType.DOCUMENT_CONTENT_INVALID`로 분류되고, 재처리 불가 정책을 그대로 상속받는다. 새 `IndexingFailureType`은 만들지 않았다.
+- **스코프에서 제외한 것**: OCR 등 대체 추출 경로 도입, 임계치의 `application.yml` 설정화. 둘 다 이번 최소 구현 범위를 벗어난다고 판단해 뺐다.
+
+## API 명세
+
+문서 업로드/버전 등록 시 PDF 파싱 단계에서 발생하는 에러 케이스가 하나 추가된다 (엔드포인트 자체는 변경 없음).
+
+**에러 케이스**
+
+| 상황 | 응답 |
+| --- | --- |
+| 문서 전체 대비 `�` 비율이 5% 초과 | `422 DOCUMENT_CONTENT_GARBLED` (DOCUMENT-PARSING-008) |
+| (기존, 변경 없음) 암호화된 PDF | `422 DOCUMENT_PDF_ENCRYPTED` |
+| (기존, 변경 없음) 텍스트를 찾을 수 없음 | `422 DOCUMENT_OCR_REQUIRED` |
+
+Worker 인덱싱 파이프라인에서는 `WorkerIndexingFailureClassifier`가 이 에러를 기존 `DOCUMENT_CONTENT_INVALID` 실패 유형으로 분류해, 관리자 화면에 다른 문서 내용 오류와 동일하게 노출된다.
+
+## 변경 파일
+
+- `ErrorCode.java` — `DOCUMENT_CONTENT_GARBLED` (DOCUMENT-PARSING-008) 추가
+- `PdfDocumentParser.java` — `isGarbled()` 비율 검사 추가, Segment 추출 후 임계치 초과 시 차단
+- `WorkerIndexingFailureClassifier.java` — `DOCUMENT_CONTENT_ERRORS` 집합에 새 에러코드 등록
+
+## 테스트
+
+- `PdfDocumentParserTest` — `isGarbled()` 임계치 이하/초과/빈 리스트 3케이스, `parseDocument()` 정상 텍스트 오탐 방지 회귀 1케이스 추가
+- `WorkerIndexingFailureClassifierTest` — 새 에러코드가 `DOCUMENT_CONTENT_INVALID`로 분류되는지 검증 추가
+- `./backend/gradlew -p backend test --tests "*PdfDocumentParserTest*" --tests "*WorkerIndexingFailureClassifierTest*"` 통과
+
+**알려진 한계**: PDFBox가 실제로 `�`를 출력하는 상황은 폰트 ToUnicode CMap이 깨진 손상 PDF에서만 발생하는데, 기존 테스트 헬퍼(Standard14 Helvetica + WinAnsiEncoding)로는 이런 손상을 인위적으로 만들 수 없다(WinAnsiEncoding이 U+FFFD 자체를 인코딩하지 못해 PDF 생성 단계에서 예외가 난다). 그래서 `parseDocument()` 레벨의 "실제 손상 PDF → `DOCUMENT_CONTENT_GARBLED` 발생" 통합 테스트는 이번에 포함하지 않았다. 실제로 문제가 발생했던 PDF 파일을 확보하면 fixture로 추가해 통합 테스트를 보강하는 것을 후속 작업으로 남긴다.