Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
## 2026-04-28

### 변경됨
- 이슈 #371 대응으로 `studio-platform-thumbnail`에 PPTX/DOCX/HWP/HWPX 문서 썸네일 renderer를 추가했다.
- PPTX는 Apache POI slide renderer로 실제 slide thumbnail을 생성하고, DOCX/HWP/HWPX는 `FileContentExtractionService`의 구조화 추출 결과로 preview thumbnail을 생성한다.
- `studio.thumbnail.renderers.<format>.*` configuration metadata 및 README 예시를 추가했다.
- 문서 썸네일 renderer는 PPTX package pre-scan, HWP/HWPX aggregate extraction budget, deterministic failure memoization으로 반복 파싱과 압축 확장 위험을 줄였다.
- attachment 썸네일은 저장된 썸네일이 없을 때 pending placeholder 이미지를 즉시 반환하고, 실제 생성은 백그라운드 executor에서 수행하도록 변경했다. 변환 실패는 bounded TTL 캐시에 memoize하고 동일 source의 동시 요청은 하나의 background job으로 합친다.
- 이슈 #368 대응으로 독립 `studio-platform-thumbnail` SPI와 `studio-platform-thumbnail-starter`를 추가해 image/PDF 썸네일 생성을 attachment 도메인 밖으로 분리했다.
- attachment 썸네일 endpoint와 저장소 구조는 유지하되, 기존 `ThumbnailServiceImpl`은 `ThumbnailGenerationService`에 위임하도록 변경했다.
- 썸네일 생성 기본값은 `studio.thumbnail.*`로 이동하고, `studio.attachment.thumbnail.default-size/default-format` 및 기존 `studio.features.attachment.thumbnail.default-size/default-format`는 fallback과 deprecation warning을 유지한다.
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,17 @@ studio:
renderers:
pdf:
enabled: false
pptx:
enabled: false
slide: 0
docx:
enabled: false
hwp:
enabled: false
hwpx:
enabled: false
# PPTX는 Apache POI slide renderer를 사용하고,
# DOCX/HWP/HWPX는 textract 결과로 preview 썸네일을 만든다.
user:
password-policy:
min-length: 12
Expand Down
15 changes: 14 additions & 1 deletion starter/studio-application-starter-attachment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// PDF 썸네일을 사용할 때만
implementation("org.apache.pdfbox:pdfbox")
// PPTX 썸네일을 사용할 때만
implementation("org.apache.poi:poi-ooxml")
// DOCX/HWP/HWPX preview 썸네일을 사용할 때만
implementation(project(":starter:studio-platform-textract-starter"))
}
```

Expand Down Expand Up @@ -66,6 +70,15 @@ studio:
pdf:
enabled: false # PDFBox classpath가 있고 명시적으로 true일 때만 등록
page: 0
pptx:
enabled: false # POI 기반 opt-in renderer
slide: 0
docx:
enabled: false # textract preview 기반 opt-in renderer
hwp:
enabled: false # textract preview 기반 opt-in renderer
hwpx:
enabled: false # textract preview 기반 opt-in renderer
```

### 스토리지 타입
Expand Down Expand Up @@ -131,7 +144,7 @@ attachment-service를 직접 사용하는 경우에는 스타터 없이 모듈

- `studio.features.attachment.enabled=false`로 전체 비활성화할 수 있다.
- 운영 환경에서는 `studio.attachment.storage.base-dir`와 `studio.attachment.thumbnail.base-dir`를 애플리케이션 전용 private 경로로 명시하고 쓰기 권한을 확인한다. 경로를 비우면 tmp 하위 기본 경로를 사용한다.
- PDF 썸네일은 PDFBox가 classpath에 있고 `studio.thumbnail.renderers.pdf.enabled=true`를 명시했을 때만 생성된다. 없으면 image renderer만 등록된다.
- PDF/PPTX/DOCX/HWP/HWPX 썸네일은 `studio.thumbnail.renderers.<format>.enabled=true`를 명시했을 때만 생성된다. PDF는 PDFBox, PPTX는 POI, DOCX/HWP/HWPX는 textract `FileContentExtractionService`가 필요하며, 조건이 없으면 image renderer만 등록되거나 해당 source를 지원하지 않는 것으로 처리된다. 저장된 썸네일이 없으면 `/thumbnail`은 `X-Thumbnail-Status: pending` 헤더와 함께 placeholder 이미지를 즉시 반환하고, starter가 등록한 `attachmentThumbnailExecutor`에서 실제 생성을 수행한다. 직접 `ThumbnailServiceImpl`를 구성할 때도 비동기 동작이 필요하면 executor를 받는 생성자를 사용한다. 변환 불가 문서는 bounded TTL 실패 상태를 memoize하고 이후 `X-Thumbnail-Status: unavailable` 204를 반환한다. DOCX/HWP/HWPX preview는 textract parser 표면을 사용하므로 필요한 경우에만 켜고, `studio.textract.max-extract-size`는 압축 입력 크기 제한으로 보수적으로 설정한다. DOCX parser는 압축 해제 work에 별도 entry/total budget을 적용한다.
- DB 스토리지 사용 시 `TB_APPLICATION_ATTACHMENT_DATA` 테이블(BLOB 컬럼 포함)이 준비되어 있어야 한다.
- 업로드 최대 크기는 컨트롤러 수준에서 50 MB로 제한된다.
- 권한 스코프(`features:attachment/*`)를 인가 서버 또는 ACL에 등록해야 한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;

import jakarta.persistence.EntityManagerFactory;

Expand All @@ -43,6 +45,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.util.StringUtils;
Expand Down Expand Up @@ -212,6 +215,22 @@ ThumbnailStorage thumbnailStorage(
return new LocalThumbnailStore(baseDir);
}

@Bean
@ConditionalOnAttachmentThumbnailEnabled
@ConditionalOnMissingBean(name = "attachmentThumbnailExecutor")
Executor attachmentThumbnailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("studio-thumbnail-");
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(100);
executor.setRejectedExecutionHandler((runnable, pool) -> {
throw new RejectedExecutionException("attachment thumbnail queue is full");
});
executor.initialize();
return executor;
}

@Bean
@ConditionalOnAttachmentThumbnailEnabled
@ConditionalOnBean(ThumbnailGenerationService.class)
Expand All @@ -220,11 +239,16 @@ ThumbnailService thumbnailService(
AttachmentService attachmentService,
ThumbnailStorage thumbnailStorage,
ThumbnailGenerationService thumbnailGenerationService,
@Qualifier("attachmentThumbnailExecutor") Executor attachmentThumbnailExecutor,
ObjectProvider<I18n> i18nProvider) {
I18n i18n = I18nUtils.resolve(i18nProvider);
log.info(LogUtils.format(i18n, I18nKeys.AutoConfig.Feature.Service.DETAILS, FEATURE_NAME,
LogUtils.blue(ThumbnailServiceImpl.class, true), LogUtils.red(State.CREATED.toString())));
return new ThumbnailServiceImpl(attachmentService, thumbnailStorage, thumbnailGenerationService);
return new ThumbnailServiceImpl(
attachmentService,
thumbnailStorage,
thumbnailGenerationService,
attachmentThumbnailExecutor);
}

private String resolveBaseDir(AttachmentProperties.Storage storage, Repository repository) {
Expand Down
21 changes: 20 additions & 1 deletion starter/studio-platform-thumbnail-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@

`studio-platform-thumbnail`의 renderer와 `ThumbnailGenerationService`를 자동 구성하는 스타터다.

PDF renderer는 보안상 기본 비활성화이며, 사용하려면 애플리케이션 런타임에 PDFBox 의존성을 직접 추가하고 설정을 켠다.
PDF/PPTX/DOCX/HWP/HWPX renderer는 보안상 기본 비활성화이며, 사용하려면 애플리케이션 런타임에 필요한 의존성과 설정을 준비한다.

```kotlin
dependencies {
implementation(project(":starter:studio-platform-thumbnail-starter"))
// PDF renderer에 필요
implementation("org.apache.pdfbox:pdfbox")
// PPTX renderer에 필요
implementation("org.apache.poi:poi-ooxml")
// DOCX/HWP/HWPX preview renderer에 필요
implementation(project(":starter:studio-platform-textract-starter"))
}
```

PPTX renderer는 Apache POI로 slide를 직접 그린다. DOCX/HWP/HWPX renderer는 textract의 구조화 추출 결과를 preview 이미지로 그리므로, 문서 레이아웃의 정확한 rasterize가 아니라 검색/관리 화면용 대표 preview다.

```yaml
studio:
features:
Expand All @@ -29,9 +36,21 @@ studio:
pdf:
enabled: false
page: 0
pptx:
enabled: false
slide: 0
docx:
enabled: false
hwp:
enabled: false
hwpx:
enabled: false
```

- `ImageThumbnailRenderer`는 기본 등록된다.
- `PdfThumbnailRenderer`는 PDFBox가 classpath에 있고 `studio.thumbnail.renderers.pdf.enabled=true`를 명시했을 때만 등록된다. PDF는 복잡한 외부 입력을 파싱/렌더링하므로 기본값은 false다.
- `PptxThumbnailRenderer`는 POI OOXML이 classpath에 있고 `studio.thumbnail.renderers.pptx.enabled=true`일 때 등록된다.
- DOCX/HWP/HWPX preview renderer는 `FileContentExtractionService` bean이 있고 각 renderer를 명시적으로 enabled 했을 때 등록된다. 지원 renderer가 없거나 추출할 수 없는 문서는 attachment `/thumbnail`에서 204를 반환한다.
- DOCX/HWP/HWPX preview renderer는 textract parser 표면을 함께 사용하므로 필요한 경우에만 켜고, `studio.textract.max-extract-size`를 운영 환경에 맞게 보수적으로 유지한다.
- `studio.attachment.thumbnail.default-size/default-format`와 `studio.features.attachment.thumbnail.default-size/default-format`는 migration window 동안 `studio.thumbnail.default-size/default-format`의 fallback으로만 읽고 WARN을 출력한다.
- attachment 모듈은 endpoint와 저장소 계약을 유지하고, 실제 생성은 이 스타터가 제공하는 `ThumbnailGenerationService`를 사용한다.
3 changes: 3 additions & 0 deletions starter/studio-platform-thumbnail-starter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ dependencies {
api(project(":studio-platform-thumbnail"))
api(project(":studio-platform-autoconfigure"))
implementation("org.springframework.boot:spring-boot-starter-validation")
compileOnly(project(":studio-platform-textract"))
compileOnly("org.apache.pdfbox:pdfbox:${property("apachePdfBoxVersion")}")

testImplementation(project(":studio-platform-textract"))
testImplementation("org.apache.pdfbox:pdfbox:${property("apachePdfBoxVersion")}")
testImplementation("org.apache.poi:poi-ooxml:${property("apachePoiVersion")}")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package studio.one.platform.thumbnail.autoconfigure;

import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import studio.one.platform.textract.model.ParsedFile;
import studio.one.platform.textract.service.FileContentExtractionService;
import studio.one.platform.thumbnail.ThumbnailFormats;
import studio.one.platform.thumbnail.ThumbnailGenerationException;
import studio.one.platform.thumbnail.ThumbnailImages;
import studio.one.platform.thumbnail.ThumbnailOptions;
import studio.one.platform.thumbnail.ThumbnailRenderer;
import studio.one.platform.thumbnail.ThumbnailResult;
import studio.one.platform.thumbnail.ThumbnailSource;
import studio.one.platform.thumbnail.renderer.DocxThumbnailRenderer;
import studio.one.platform.thumbnail.renderer.HwpThumbnailRenderer;
import studio.one.platform.thumbnail.renderer.HwpxThumbnailRenderer;

class TextractDocumentPreviewThumbnailRenderer implements ThumbnailRenderer {

private static final int CANVAS_WIDTH = 480;
private static final int CANVAS_HEIGHT = 640;
private static final int PADDING = 36;
private static final int MAX_LINES = 14;
private static final int MAX_CHARS = 1200;

private final FileContentExtractionService extractionService;
private final String label;
private final Set<String> contentTypes;
private final Set<String> extensions;

protected TextractDocumentPreviewThumbnailRenderer(
FileContentExtractionService extractionService,
String label,
Set<String> contentTypes,
Set<String> extensions) {
this.extractionService = extractionService;
this.label = label;
this.contentTypes = Set.copyOf(contentTypes);
this.extensions = Set.copyOf(extensions);
}

static DocxThumbnailRenderer docx(FileContentExtractionService extractionService) {
return new TextractDocxPreviewThumbnailRenderer(extractionService);
}

static HwpThumbnailRenderer hwp(FileContentExtractionService extractionService) {
return new TextractHwpPreviewThumbnailRenderer(extractionService);
}

static HwpxThumbnailRenderer hwpx(FileContentExtractionService extractionService) {
return new TextractHwpxPreviewThumbnailRenderer(extractionService);
}

@Override
public boolean supports(ThumbnailSource source) {
String contentType = source.contentType().toLowerCase(Locale.ROOT);
if (contentTypes.contains(contentType)) {
return true;
}
String filename = source.filename().toLowerCase(Locale.ROOT);
return extensions.stream().anyMatch(filename::endsWith);
}

@Override
public ThumbnailResult render(ThumbnailSource source, ThumbnailOptions options) {
try {
ParsedFile parsed = extractionService.parseStructured(
source.contentType(),
source.filename(),
new ByteArrayInputStream(source.bytes()));
BufferedImage preview = drawPreview(source.filename(), parsed);
BufferedImage scaled = ThumbnailImages.scale(preview, options.size());
byte[] bytes = ThumbnailImages.write(scaled, options.format());
return new ThumbnailResult(bytes, ThumbnailFormats.contentType(options.format()), options.format());
} catch (Exception ex) {
throw new ThumbnailGenerationException("Failed to render document preview thumbnail source", ex);
}
}

private BufferedImage drawPreview(String filename, ParsedFile parsed) {
BufferedImage image = new BufferedImage(CANVAS_WIDTH, CANVAS_HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
graphics.setColor(new Color(248, 250, 252));
graphics.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
graphics.setColor(new Color(45, 65, 89));
graphics.fillRect(0, 0, CANVAS_WIDTH, 88);

graphics.setColor(Color.WHITE);
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 30));
graphics.drawString(label, PADDING, 55);

graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 15));
graphics.drawString(trim(filename, 36), PADDING + 110, 55);

graphics.setColor(new Color(226, 232, 240));
graphics.fillRect(PADDING, 118, CANVAS_WIDTH - (PADDING * 2), 2);

graphics.setColor(new Color(15, 23, 42));
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 20));
graphics.drawString(trim(title(filename, parsed), 34), PADDING, 158);

graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 17));
graphics.setColor(new Color(51, 65, 85));
FontMetrics metrics = graphics.getFontMetrics();
int y = 200;
for (String line : previewLines(parsed.plainText(), metrics, CANVAS_WIDTH - (PADDING * 2))) {
graphics.drawString(line, PADDING, y);
y += 28;
}

graphics.setColor(new Color(100, 116, 139));
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 14));
String footer = parsed.blocks().size() + " blocks / " + parsed.tables().size()
+ " tables / " + parsed.images().size() + " images";
graphics.drawString(footer, PADDING, CANVAS_HEIGHT - 34);
return image;
} finally {
graphics.dispose();
}
}

private String title(String filename, ParsedFile parsed) {
return parsed.plainText().lines()
.map(String::trim)
.filter(line -> !line.isBlank())
.findFirst()
.orElse(filename == null || filename.isBlank() ? label + " document" : filename);
}

private List<String> previewLines(String text, FontMetrics metrics, int maxWidth) {
String normalized = text == null ? "" : text.replaceAll("\\s+", " ").trim();
if (normalized.length() > MAX_CHARS) {
normalized = normalized.substring(0, MAX_CHARS);
}
if (normalized.isBlank()) {
return List.of("No preview text extracted.");
}
List<String> lines = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (String word : normalized.split(" ")) {
String candidate = current.isEmpty() ? word : current + " " + word;
if (metrics.stringWidth(candidate) > maxWidth && !current.isEmpty()) {
lines.add(current.toString());
current.setLength(0);
current.append(word);
if (lines.size() == MAX_LINES) {
return lines;
}
} else {
current.setLength(0);
current.append(candidate);
}
}
if (!current.isEmpty() && lines.size() < MAX_LINES) {
lines.add(current.toString());
}
return lines;
}

private String trim(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value == null ? "" : value;
}
return value.substring(0, Math.max(0, maxLength - 3)) + "...";
}

private static final class TextractDocxPreviewThumbnailRenderer
extends TextractDocumentPreviewThumbnailRenderer implements DocxThumbnailRenderer {

private TextractDocxPreviewThumbnailRenderer(FileContentExtractionService extractionService) {
super(
extractionService,
"DOCX",
Set.of("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
Set.of(".docx"));
}
}

private static final class TextractHwpPreviewThumbnailRenderer
extends TextractDocumentPreviewThumbnailRenderer implements HwpThumbnailRenderer {

private TextractHwpPreviewThumbnailRenderer(FileContentExtractionService extractionService) {
super(
extractionService,
"HWP",
Set.of("application/x-hwp", "application/haansofthwp", "application/vnd.hancom.hwp"),
Set.of(".hwp"));
}
}

private static final class TextractHwpxPreviewThumbnailRenderer
extends TextractDocumentPreviewThumbnailRenderer implements HwpxThumbnailRenderer {

private TextractHwpxPreviewThumbnailRenderer(FileContentExtractionService extractionService) {
super(
extractionService,
"HWPX",
Set.of("application/x-hwpx", "application/vnd.hancom.hwpx", "application/hwpx"),
Set.of(".hwpx"));
}
}
}
Loading
Loading