parent
3e96932399
commit
301fd7a617
@ -0,0 +1,254 @@
|
||||
package go.kr.project.common.controller;
|
||||
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import javax.servlet.RequestDispatcher;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpServletResponseWrapper;
|
||||
import java.io.*;
|
||||
|
||||
/**
|
||||
* JSP -> HTML -> PDF 변환 샘플 컨트롤러
|
||||
* - OpenHTMLtoPDF(PdfRendererBuilder) + PDFBox 를 사용
|
||||
* - JSP를 내부 include로 렌더링하여 HTML 문자열을 생성 후 PDF로 변환
|
||||
* - 중요 로직 한글 주석 필수
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/common/pdf")
|
||||
@Tag(name = "Common PDF", description = "JSP를 PDF로 변환하는 샘플 API")
|
||||
public class CommonPdfController {
|
||||
|
||||
/**
|
||||
* PDF 미리보기(브라우저 내장 뷰어에 inline으로 표시)
|
||||
* URL: /common/pdf/view
|
||||
*/
|
||||
@Operation(summary = "JSP를 PDF로 변환하여 브라우저에 표시", description = "JSP를 HTML로 렌더링한 후 즉시 PDF로 변환하여 inline으로 응답합니다.")
|
||||
@GetMapping(value = "/view")
|
||||
public void viewPdf(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
|
||||
// [보안] 세션 체크 예시 (프로젝트 정책에 맞춰 실제 인증 객체/세션 키로 대체 가능)
|
||||
// Object user = request.getSession(false) != null ? request.getSession(false).getAttribute("loginUser") : null;
|
||||
// if (user == null) {
|
||||
// response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// 1) JSP를 HTML 문자열로 렌더링
|
||||
// - Tiles가 아닌 독립 JSP를 대상으로 함(샘플)
|
||||
String jspPath = "/WEB-INF/views/common/pdf/sample.jsp"; // 내부 경로
|
||||
String html = renderJspToHtml(request, response, jspPath);
|
||||
|
||||
// 2) 응답 헤더 설정: inline PDF
|
||||
response.setContentType(MediaType.APPLICATION_PDF_VALUE);
|
||||
response.setHeader("Content-Disposition", "inline; filename=sample.pdf");
|
||||
|
||||
// 3) HTML -> PDF 변환 수행
|
||||
// - openhtmltopdf PdfRendererBuilder 사용
|
||||
// - setBaseUri를 null로 두면 상대 리소스 경로 해석이 어려울 수 있으므로, 필요 시 컨텍스트 기반 URL 설정 가능
|
||||
try (OutputStream os = response.getOutputStream()) {
|
||||
PdfRendererBuilder builder = new PdfRendererBuilder();
|
||||
builder.useFastMode();
|
||||
// 한글 폰트가 누락될 경우 폰트 설정이 필요할 수 있음(서버 폰트 파일을 자동 탐색/등록)
|
||||
registerKoreanFonts(builder);
|
||||
builder.withHtmlContent(html, null);
|
||||
builder.toStream(os);
|
||||
builder.run();
|
||||
} catch (Exception e) {
|
||||
// 변환 실패 시 500 에러 응답
|
||||
response.reset();
|
||||
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "PDF 변환 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenHTMLtoPDF에 한글 폰트를 등록하여 CJK 글꼴이 깨지지 않도록 처리
|
||||
* - 운영체제별로 많이 사용되는 시스템 폰트를 우선 탐색하여 등록 (Windows: Malgun Gothic, Linux: NanumGothic 등)
|
||||
* - 존재하지 않는 경로는 무시
|
||||
* - 주의: 시스템 폰트 경로는 서버 환경에 따라 다를 수 있음
|
||||
*/
|
||||
private void registerKoreanFonts(PdfRendererBuilder builder) {
|
||||
// [중요 로직] 한글 폰트 경로 후보 (운영체제별)
|
||||
String[] candidates = new String[] {
|
||||
// Windows
|
||||
"C:\\Windows\\Fonts\\malgun.ttf",
|
||||
"C:\\Windows\\Fonts\\Malgun.ttf",
|
||||
"C:\\Windows\\Fonts\\malgunbd.ttf",
|
||||
// Linux (Nanum)
|
||||
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
|
||||
"/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf",
|
||||
// Linux (Noto CJK - 경로는 배포판에 따라 상이할 수 있음)
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJKkr-Regular.otf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
|
||||
// macOS
|
||||
"/Library/Fonts/AppleSDGothicNeo.ttc"
|
||||
};
|
||||
for (String path : candidates) {
|
||||
try {
|
||||
File f = new File(path);
|
||||
if (f.exists() && f.isFile()) {
|
||||
// [중요 로직] CSS에서 사용하는 패밀리명으로 별칭 등록하여 매칭률 향상
|
||||
String lower = path.toLowerCase();
|
||||
if (lower.contains("malgun")) {
|
||||
builder.useFont(f, "Malgun Gothic"); // CSS: 'Malgun Gothic'
|
||||
builder.useFont(f, "malgun"); // 보조 별칭
|
||||
} else if (lower.contains("nanumgothic")) {
|
||||
builder.useFont(f, "NanumGothic"); // CSS: 'NanumGothic'
|
||||
builder.useFont(f, "Nanum Gothic"); // 보조 별칭
|
||||
} else if (lower.contains("notosanscjk")) {
|
||||
builder.useFont(f, "Noto Sans CJK KR"); // CSS: 'Noto Sans CJK KR'
|
||||
builder.useFont(f, "NotoSansCJKkr"); // 보조 별칭
|
||||
} else if (lower.contains("applesdgothicneo")) {
|
||||
builder.useFont(f, "Apple SD Gothic Neo"); // CSS: 'Apple SD Gothic Neo'
|
||||
} else {
|
||||
// 알 수 없는 경우 파일명 기반으로도 등록
|
||||
builder.useFont(f, f.getName());
|
||||
}
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 폰트 등록 실패는 무시하고 다음 후보 시도
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF.js 뷰어 페이지를 반환
|
||||
* URL: /common/pdf/viewer
|
||||
*/
|
||||
@Operation(summary = "PDF.js 뷰어 페이지", description = "PDF.js를 이용해 생성된 PDF를 페이지 내 캔버스에 렌더링합니다.")
|
||||
@GetMapping(value = "/viewer")
|
||||
public String viewerPage(HttpServletRequest request) {
|
||||
// [보안] 세션 체크 예시
|
||||
// Object user = request.getSession(false) != null ? request.getSession(false).getAttribute("loginUser") : null;
|
||||
// if (user == null) { return "redirect:/login"; }
|
||||
// Tiles 정의 없이도 JSP를 바로 호출할 수 있도록 forward 사용
|
||||
return "forward:/WEB-INF/views/common/pdf/pdf-viewer.jsp";
|
||||
}
|
||||
|
||||
/**
|
||||
* <object> 태그로 PDF를 페이지 내에 직접 임베딩하여 표시
|
||||
* URL: /common/pdf/object-view
|
||||
* - 단순 미리보기/다운로드만 필요할 때 적합
|
||||
*/
|
||||
@Operation(summary = "<object> 기반 PDF 표시", description = "브라우저의 기본 PDF 뷰어를 사용해 <object>로 PDF를 표시하고, 상단에 다운로드/이동 버튼을 제공합니다.")
|
||||
@GetMapping(value = "/object-view")
|
||||
public String objectViewPage(HttpServletRequest request) {
|
||||
// [보안] 세션 체크 예시
|
||||
// Object user = request.getSession(false) != null ? request.getSession(false).getAttribute("loginUser") : null;
|
||||
// if (user == null) { return "redirect:/login"; }
|
||||
return "forward:/WEB-INF/views/common/pdf/pdf-object-view.jsp";
|
||||
}
|
||||
|
||||
/**
|
||||
* 접속 환경에 따라 적절한 PDF 뷰 방식을 자동 선택
|
||||
* URL: /common/pdf/responsive-view
|
||||
* - 모바일: PDF.js 뷰어로 렌더링(호환성 높음)
|
||||
* - 데스크톱: <object> 기반 내장 뷰어 사용
|
||||
*/
|
||||
@Operation(summary = "반응형 PDF 보기", description = "모바일에서는 PDF.js, 데스크톱에서는 <object> 기반으로 자동 전환합니다.")
|
||||
@GetMapping(value = "/responsive-view")
|
||||
public String responsiveView(HttpServletRequest request) {
|
||||
// [보안] 세션 체크 예시
|
||||
// Object user = request.getSession(false) != null ? request.getSession(false).getAttribute("loginUser") : null;
|
||||
// if (user == null) { return "redirect:/login"; }
|
||||
if (isMobile(request)) {
|
||||
return "forward:/WEB-INF/views/common/pdf/pdf-viewer.jsp";
|
||||
}
|
||||
return "forward:/WEB-INF/views/common/pdf/pdf-object-view.jsp";
|
||||
}
|
||||
|
||||
// [중요 로직] 단순 User-Agent 기반 모바일 환경 감지
|
||||
private boolean isMobile(HttpServletRequest request) {
|
||||
String ua = request.getHeader("User-Agent");
|
||||
if (ua == null) return false;
|
||||
String u = ua.toLowerCase();
|
||||
return u.contains("mobile") || u.contains("android") || u.contains("iphone") || u.contains("ipad") || u.contains("ipod") || u.contains("windows phone");
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 PDF 생성 및 전송(미리보기/다운로드 겸용)
|
||||
* URL: /common/pdf/generate
|
||||
* 파라미터: download=true 이면 첨부 다운로드, 아니면 inline 미리보기
|
||||
*/
|
||||
@Operation(summary = "실시간 PDF 생성", description = "JSP(혹은 문자열) 기반 HTML을 PDF로 변환하여 스트리밍합니다. download 파라미터로 다운로드/미리보기 제어")
|
||||
@GetMapping(value = "/generate")
|
||||
public void generatePdf(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
// [보안] 세션 체크 예시
|
||||
// Object user = request.getSession(false) != null ? request.getSession(false).getAttribute("loginUser") : null;
|
||||
// if (user == null) {
|
||||
// response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
boolean download = "true".equalsIgnoreCase(request.getParameter("download"));
|
||||
|
||||
// 1) PDF로 변환할 HTML 콘텐츠 준비 (샘플)
|
||||
// - 실제로는 JSP 렌더링 결과를 사용해도 됨. 여기서는 간단한 문자열 예시.
|
||||
String html = "<html><head><meta charset='UTF-8'/><style>body{font-family:'Malgun Gothic','NanumGothic','Apple SD Gothic Neo',sans-serif;}</style></head><body>" +
|
||||
"<h1>고지서</h1>" +
|
||||
"<p>" + java.time.LocalDate.now() + " 기준 고지서입니다.</p>" +
|
||||
"<table border='1' cellspacing='0' cellpadding='5'><tr><th>항목</th><th>금액</th></tr>" +
|
||||
"<tr><td>서비스 이용료</td><td>10,000원</td></tr>" +
|
||||
"<tr><td>부가세</td><td>1,000원</td></tr>" +
|
||||
"<tr><td><b>합계</b></td><td><b>11,000원</b></td></tr></table>" +
|
||||
"</body></html>";
|
||||
|
||||
// 2) HTML -> PDF 변환 (메모리 스트림)
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
PdfRendererBuilder builder = new PdfRendererBuilder();
|
||||
builder.useFastMode();
|
||||
// [중요] 한글 깨짐(# 표시) 방지: 폰트 등록 필수
|
||||
registerKoreanFonts(builder);
|
||||
builder.withHtmlContent(html, null);
|
||||
builder.toStream(baos);
|
||||
builder.run();
|
||||
|
||||
byte[] pdfData = baos.toByteArray();
|
||||
|
||||
// 3) 응답 헤더 구성: 다운로드 여부에 따라 분기
|
||||
response.setContentType(MediaType.APPLICATION_PDF_VALUE);
|
||||
response.setHeader("Content-Disposition", (download ? "attachment" : "inline") + "; filename=notice.pdf");
|
||||
response.setContentLength(pdfData.length);
|
||||
|
||||
// 4) 스트리밍 전송
|
||||
try (OutputStream out = response.getOutputStream()) {
|
||||
out.write(pdfData);
|
||||
out.flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
response.reset();
|
||||
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "PDF 생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSP를 내부 include로 렌더링하여 HTML 문자열을 반환
|
||||
* - Response를 감싸서 getWriter()로 출력될 내용을 StringWriter에 기록
|
||||
*/
|
||||
private String renderJspToHtml(HttpServletRequest request, HttpServletResponse originalResponse, String jspPath)
|
||||
throws ServletException, IOException {
|
||||
// JSP 출력물을 담을 버퍼
|
||||
final StringWriter stringWriter = new StringWriter();
|
||||
|
||||
// HttpServletResponseWrapper 구현: JSP의 Writer 출력을 가로채서 stringWriter에 기록
|
||||
HttpServletResponseWrapper capturingResponse = new HttpServletResponseWrapper(originalResponse) {
|
||||
private final PrintWriter writer = new PrintWriter(stringWriter);
|
||||
@Override
|
||||
public PrintWriter getWriter() {
|
||||
return writer;
|
||||
}
|
||||
};
|
||||
|
||||
// RequestDispatcher.include를 사용하여 JSP를 현재 요청/응답 컨텍스트로 렌더링
|
||||
RequestDispatcher dispatcher = request.getRequestDispatcher(jspPath);
|
||||
dispatcher.include(request, capturingResponse);
|
||||
|
||||
return stringWriter.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>PDF 미리보기 (<object> 기반)</title>
|
||||
<style>
|
||||
/* [화면 스타일] - 프로젝트 전반 스타일 영향 최소화 위해 페이지 로컬 스타일만 사용 */
|
||||
body { font-family: 'Malgun Gothic','NanumGothic','Apple SD Gothic Neo',sans-serif; margin: 16px; }
|
||||
.toolbar { margin-bottom: 10px; }
|
||||
.toolbar button { padding: 6px 10px; margin-right: 6px; }
|
||||
.pdf-host { border: 1px solid #ddd; background: #fefefe; }
|
||||
.pdf-object { width: 100%; height: calc(100vh - 120px); border: 0; }
|
||||
.fallback { padding: 12px; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>고지서 미리보기</h2>
|
||||
|
||||
<!-- [툴바] - 다운로드/이동 기능 제공 -->
|
||||
<div class="toolbar">
|
||||
<!-- 다운로드: 서버 엔드포인트에 download=true를 전달하여 파일로 저장 -->
|
||||
<a class="btn" href="<c:url value='/common/pdf/generate?download=true'/>"><button type="button">다운로드</button></a>
|
||||
<!-- 새 창으로 열기: 브라우저 기본 PDF 뷰어로 새 탭 표시 -->
|
||||
<button type="button" onclick="openNewTab()">새 창으로 열기</button>
|
||||
<!-- 뒤로가기: 사용자의 이전 페이지로 이동 -->
|
||||
<button type="button" onclick="history.back()">이전 페이지</button>
|
||||
</div>
|
||||
|
||||
<!-- [PDF 표시] - <object>를 사용하여 브라우저 기본 PDF 뷰어로 표시 -->
|
||||
<div class="pdf-host">
|
||||
<object id="pdfObject" class="pdf-object" data="<c:url value='/common/pdf/generate'/>" type="application/pdf">
|
||||
<div class="fallback">
|
||||
PDF를 표시할 수 없습니다.
|
||||
<a href="<c:url value='/common/pdf/generate?download=true'/>">여기를 클릭</a>하여 파일을 다운로드하세요.
|
||||
</div>
|
||||
</object>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// [이동 버튼] 새 탭으로 PDF 열기
|
||||
function openNewTab() {
|
||||
const url = '<c:url value="/common/pdf/generate"/>';
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
// 주의: <object>로 임베딩된 PDF 내부 페이지를 JS로 직접 제어하기는 브라우저/뷰어 제약으로 불가합니다.
|
||||
// 따라서 페이지 밖(상단 툴바)에 다운로드/이동 기능을 제공합니다.
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,79 @@
|
||||
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<meta>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>PDF.js 뷰어 - 고지서 미리보기</title>
|
||||
<!-- 중요: 배포 시 로컬 pdfjs를 사용할 경우, 아래 CDN 스크립트를 /pdfjs/pdf.js 로 교체 가능 -->
|
||||
<!-- <script src="<c:url value='/pdfjs/pdf.js'/>\"></script> -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
|
||||
<style>
|
||||
/* 화면 스타일: 캔버스 배경, 레이아웃 등 */
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
h2 { margin-bottom: 10px; }
|
||||
#viewerWrapper { border: 1px solid #ddd; padding: 10px; display: inline-block; background: #fefefe; }
|
||||
#pdf-canvas { background: #fff; border: 1px solid #ccc; }
|
||||
.toolbar { margin: 10px 0; }
|
||||
.toolbar button { padding: 6px 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>고지서 미리보기</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" onclick="downloadPDF()">다운로드</button>
|
||||
</div>
|
||||
<div id="viewerWrapper">
|
||||
<canvas id="pdf-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// PDF.js 워커 설정 (CDN 사용)
|
||||
// 로컬 사용 시: pdfjsLib.GlobalWorkerOptions.workerSrc = '<c:url value="/pdfjs/pdf.worker.js"/>';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
|
||||
|
||||
// 실시간 PDF 생성 엔드포인트 (Spring Controller)
|
||||
const pdfUrl = '<c:url value="/common/pdf/generate"/>';
|
||||
|
||||
// PDF 첫 페이지를 캔버스에 렌더링 (화면 너비에 맞춰 자동 스케일)
|
||||
pdfjsLib.getDocument(pdfUrl).promise.then(function(pdf) {
|
||||
return pdf.getPage(1).then(function(page) {
|
||||
var canvas = document.getElementById('pdf-canvas');
|
||||
var context = canvas.getContext('2d');
|
||||
var wrapper = document.getElementById('viewerWrapper');
|
||||
|
||||
function render() {
|
||||
var unscaled = page.getViewport({ scale: 1 });
|
||||
// 가용 폭 계산(모바일 고려): wrapper 너비 또는 화면 폭 기준
|
||||
var available = Math.max(320, (wrapper.clientWidth || (window.innerWidth - 40)) - 20);
|
||||
var scale = available / unscaled.width;
|
||||
// 과대/과소 확대 제한
|
||||
if (scale > 2.0) scale = 2.0;
|
||||
if (scale < 0.7) scale = 0.7;
|
||||
var viewport = page.getViewport({ scale: scale });
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
var renderContext = { canvasContext: context, viewport: viewport };
|
||||
page.render(renderContext);
|
||||
}
|
||||
|
||||
render();
|
||||
// 창 크기 변경 시 재렌더링(간단 디바운스)
|
||||
var tid;
|
||||
window.addEventListener('resize', function(){
|
||||
clearTimeout(tid);
|
||||
tid = setTimeout(render, 150);
|
||||
});
|
||||
});
|
||||
}).catch(function(err){
|
||||
console.error('PDF 로딩 오류:', err);
|
||||
alert('PDF 로딩 중 오류가 발생했습니다.');
|
||||
});
|
||||
|
||||
// 다운로드 버튼: 동일 엔드포인트에 download=true 파라미터 전달
|
||||
function downloadPDF() {
|
||||
window.location.href = pdfUrl + '?download=true';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,25 @@
|
||||
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>PDF Sample</title>
|
||||
<style>
|
||||
/* 간단한 스타일 - PDF 변환 시 반영됨 */
|
||||
body { font-family: 'Malgun Gothic','NanumGothic','Apple SD Gothic Neo',sans-serif; }
|
||||
h1 { color: navy; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid black; padding: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PDF 변환 테스트</h1>
|
||||
<p>이 페이지는 JSP에서 바로 PDF로 변환됩니다.</p>
|
||||
<table>
|
||||
<tr><th>번호</th><th>내용</th></tr>
|
||||
<tr><td>1</td><td>샘플 데이터</td></tr>
|
||||
<tr><td>2</td><td>테스트 데이터</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue