포스트

Template Engine을 사용하여 PDF파일로 변환하기

Template Engine을 사용하여 PDF파일로 변환하기

이번에 회사에서 받은 데이터를 가지고 PDF를 만들 일이 있어서Thymeleaf를 사용하여 적용해 본 방법을 기록하고자 합니다.

의존성

  • build.gradle
1
2
3
4
5
6
7
8
9
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
  implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
  implementation('org.xhtmlrenderer:flying-saucer-pdf-openpdf:9.1.22')
  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'
}

Template Engine을 사용하여 렌더링 된 HTML을 String으로 반환받기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
@RequiredArgsConstructor
public class TemplateParser {

  public String parseHtmlFileToString(String templateName, Map<String, Object> variables) {
    // Thymeleaf Resolver 설정
    var templateResolver = new ClassLoaderTemplateResolver();
    templateResolver.setPrefix("templates/");
    templateResolver.setSuffix(".html");
    templateResolver.setTemplateMode(TemplateMode.HTML);

    // Spring Template Engine으로 위에서 설정한 Thymeleaf Resolver를 사용하도록 설정
    var templateEngine = new SpringTemplateEngine();
    templateEngine.setTemplateResolver(templateResolver);

    // Template Engine에서 사용할 변수
    var context = new Context();
    context.setVariables(variables);

    // 렌더링 된 값을 String으로 반환
    return templateEngine.process(templateName, context);
  }
}

Spring Boot에서 Template Engine 사용 시

interface TemplateParser를 만들어Template Engine마다 상속받아 구현하는 것이 더 좋은 설계이지만,Spring Boot를 이용하면 설정파일로Template Engine설정할 수 있습니다.Spring Boot는 기본적으로Thymeleaf가 설치 되어 있다면Thymeleaf설정을 해줍니다.

즉, 위에서SpringTemplateEngine을 의존주입 받아 사용하면Template Engine설정하는 부분 생략할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
@RequiredArgsConstructor
public class TemplateParser {

  private final SpringTemplateEngine templateEngine;

  public String parseHtmlFileToString(String templateName, Map<String, Object> variables) {
    var context = new Context();
    context.setVariables(variables);
    return templateEngine.process(templateName, context);
  }
}

Thymeleaf 사용하기

layout기능을 사용하기 위해선org.springframework.boot:spring-boot-starter-thymeleaf외에 nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect도 필요합니다.

  • layout/document.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html
  lang="ko"
  xmlns:th="<http://www.thymeleaf.org>"
  xmlns:layout="<http://www.ultraq.net.nz/thymeleaf/layout>"
>
<head>
  <meta charset="UTF-8"/>
  <meta
    name="viewport"
    content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
  />
  <meta http-equiv="X-UA-Compatible" content="ie=edge"/>
  <style th:replace="pdf/approval/document/partials/styles :: commonStyle"></style>
  <title th:text="${title}"></title>
</head>
<body>
<div id="main-pdf-container">
  <div th:replace="partials/header :: commonHeader"></div>
  <div th:replace="partials/commonField :: commonField"></div>
  <div layout:fragment="content"></div>
  <div th:replace="partials/attachedFiles :: commonApprovalAttachedFiles"></div>
</div>
</body>
</html>

저는 공통적으로 사용 될layouthtml을 하나 만들어서 사용했습니다.

th:replace 부분에 들어갈 파일 작성

partials/header부분은templates폴더 하위 부터 해당 파일의절대경로를 나타내며::이후commonHeader는 해당 파일 내에서th:fragment로 선언된 이름입니다.

  • partials/header.html
1
2
3
4
5
<!DOCTYPE html>
<html lang="ko" xmlns:th="<http://www.thymeleaf.org>">
<div th:fragment="commonHeader">...</div>
</html>

layout:fragment 부분에 들어갈 파일 작성

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html
  lang="ko"
  xmlns:th="<http://www.thymeleaf.org>"
  xmlns:layout="<http://www.ultraq.net.nz/thymeleaf/layout>"
  layout:decorate="~{pdf/approval/document/layout/document}"
>
<div layout:fragment="content">...</div>
</html>

html태그에xmlns:layout="<http://www.ultraq.net.nz/thymeleaf/layout> 추가해야 layout기능을 사용할 수 있습니다.

html태그에layout:decorate="~{layout/document}"를 추가함으로써 해당layout을 사용할 수 있게 됩니다.

위의<div layout:fragment="content">해당 태그 안의 내용이layout/document.html<div layout:fragment="content"></div>부분에 들어가게 됩니다.

layout:decorate="~{pdf/approval/document/layout/document}"부분을 보면~{}로 감싸져 있는데,~{}로 감싸지 않으면WARN이 발생합니다. th:blockth:replacelayout:fragment등을 사용해도WARN이 납니다. 그렇기 때문에 일반적인 태그를 사용하기 바랍니다.

HTML String으로 PDF파일 생성 후 저장하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
@RequiredArgsConstructor
public class PdfGenerator {
  public String generate(String filePath, String fileName, String html) {
    String savePath = "%s/%s".formatted(filePath, fileName);

    // 해당 경로에 폴더가 없으면 no such directory or file 에러가 발생하므로
    // 파일을 다운 받기 전에 폴더를 생성합니다.
    mkdirs(filePath);

    try (FileOutputStream fileOutputStream = new FileOutputStream(savePath)) {
      ITextRenderer renderer = new ITextRenderer();
      renderer.setDocumentFromString(html); // HTML기반으로 된 String으로 Document 형식으로 변환합니다.
      renderer.layout(); // PDF 모양을 잡아주는 메소드들이 실행됩니다. (퍼사드 패턴)

      renderer.createPDF(fileOutputStream);
      return savePath;
    } catch (IOException | DocumentException e) {
      throw new IllegalStateException("PDF를 저장하는데 실패하였습니다.", e);
    }
  }

  private void mkdirs(String filePath) {
    new File(filePath).mkdirs();
  }
}

한글 처리

위 처럼 적용했더니, 한글만 적으면 화면에 보이질 않았습니다. 한글을 보여주기 위해선한글을 지원하는 폰트를 별도로 설정을 해줬어야 했습니다.

resources/static/fonts경로에 다운 받은폰트를 넣고, 해당폰트를 적용합니다.

저는네이버 폰트에서나눔스퀘어를 받아 사용했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Component
@RequiredArgsConstructor
public class PdfGenerator {
  public String generate(String filePath, String fileName, String html) {
    String savePath = "%s/%s".formatted(filePath, fileName);

    // 해당 경로에 폴더가 없으면 no such directory or file 에러가 발생하므로
    // 파일을 다운 받기 전에 폴더를 생성합니다.
    mkdirs(filePath);

    try (FileOutputStream fileOutputStream = new FileOutputStream(savePath)) {
      ITextRenderer renderer = new ITextRenderer();

      addFonts(renderer); // 폰트 추가

      renderer.setDocumentFromString(html); // HTML기반으로 된 String으로 Document 형식으로 변환합니다.
      renderer.layout(); // PDF 모양을 잡아주는 메소드들이 실행됩니다. (퍼사드 패턴)

      renderer.createPDF(fileOutputStream);
      return savePath;
    } catch (IOException | DocumentException e) {
      throw new IllegalStateException("PDF를 저장하는데 실패하였습니다.", e);
    }
  }

  private void mkdirs(String filePath) {
    new File(filePath).mkdirs();
  }

  private void addFonts(ITextRenderer renderer) throws IOException {
    Stream.of(
      "NanumSquare_acB.ttf",
      "NanumSquare_acEB.ttf",
      "NanumSquare_acL.ttf",
      "NanumSquare_acR.ttf",
      "NanumSquareB.ttf",
      "NanumSquareEB.ttf",
      "NanumSquareL.ttf",
      "NanumSquareR.ttf"
    ).forEach(font -> {
      try {
        renderer.getFontResolver()
          .addFont(
            getFontPath(font),
            BaseFont.IDENTITY_H,
            BaseFont.EMBEDDED
          );
      } catch (IOException e) {
        throw new IllegalStateException("폰트 가져오기 실패", e);
      }
    });
  }

  private String getFontPath(String fontName) throws IOException {
    return new ClassPathResource("/static/font/%s".formatted(fontName)).getURL().toString();
  }
}

이후,Thymeleaf에서 해당 폰트를 사용하도록font-family를 설정합니다.

1
2
3
4
5
6
7
<style>
  * {
    font-family: 'NanumSquare', sans-serif;
  }
</style>

.otf폰트는 적용이 되지 않아서.ttf만 적용했습니다. addFonts부분이 해당 부분보다 아래로 가면폰트가 적용되지 않으므로, 반드시 위쪽에 위치하도록 합니다.

이제 한글도 정상적으로 나오는 것을 확인할 수 있습니다.

참고

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.