ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] html 파일(with Thymeleaf)을 pdf파일로 변환하기
    웹 개발/Spring Framework 2020. 9. 28. 23:35

    Thymeleaf engine을 사용하는 html을 pdf로 변환해서 파일로 저장하기

     

    이번 포스팅은 Spring에서 html 파일을 pdf로 변환해서 로컬에 저장하는 방법이다.

    (설명을 끝까지 봐야 제대로된 pdf 파일이 나온다)

     

    준비물

    1. Spring boot 2.3.4 

    2. Kotlin 1.3 or Java 11

    3. Thymeleaf 라이브러리

    4. flying-saucer-pdf 라이브러리

     

    1. build.gradle에 dependency 추가

    // build.gradle.kts
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
    implementation("org.xhtmlrenderer:flying-saucer-pdf:9.1.20")

     

    2. pdf로 만들어줄 html 만들기

    예제로 귀여운 펭수를 소개하는 pdf 파일을 만들어볼 것이다.

     

    thymeleaf를 사용하는 html 파일을 만들어준다. (html, css는 쥐약이므로 정말 간단한 예제를 만든다)

     

    resources/static/css 경로 밑에 bootstrap을 넣어주었다

    giant-peng.html

    <!DOCTYPE HTML>
    <html xmlns:th="http://www.thymeleaf.org">
    <head>
        <title>html을 pdf로 바꿔보기</title>
        <meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
        <meta charset='UTF-8'/>
        <meta content='Content-type: text/html; charset=UTF-8' name='http-equiv'/>
        <meta content='IE=Edge,chrome=1' http-equiv='X-UA-Compatible'/>
        <meta content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no'
              name='viewport'/>
        <link href="/css/bootstrap.min.css" media="screen" rel="stylesheet"/>
        <style>
            body {
                color: #555;
                padding: 0px;
            }
    
            .main {
                margin: auto;
                padding: 15px;
                border-radius: 1px;
            }
    
            .logo-image {
                width: 40%;
                max-width: 300px;
                float: right;
            }
    
            .main h1 {
                margin: 100px 0 10px 15px;
                width: 100%;
                font-size: 35px;
            }
    
            .info-box {
                margin-top: 40px;
                padding-right: 15px;
            }
    
            .info {
                width: 100%;
                margin-left: 15px;
            }
    
            .title {
                font-weight: lighter;
                color: #42A5F5;
                vertical-align: top;
                font-size: 17px;
            }
    
            .description {
                vertical-align: top;
                font-size: 13px;
            }
    
        </style>
    </head>
    <body>
    <div class="container main">
        <h1>귀여운 펭수</h1>
        <img class="logo-image" src="/image/cutty_peng.png"/>
        
        <div class="info-box">
            <div class="info">
                <h2 class="title">이름</h2>
                <p class="description" th:text="${name}"/>
            </div>
    
            <div class="info">
                <h2 class="title">나이</h2>
                <p class="description" th:text="${age}"/>
            </div>
    
            <div class="info">
                <h2 class="title">직업</h2>
                <p class="description" th:text="${job}"/>
            </div>
    
            <div class="info">
                <h2 class="title">거주지</h2>
                <p class="description" th:text="${address}"/>
            </div>
        </div>
    </div>
    </body>
    </html>
    

     

    3. Html을 String으로 변환하는 Parser 만들기

    Util로 사용하기 위한 Object를 생성한다. (Java에서는 Util성 클래스를 만들어 static 메서드로 만들어주면 되겠다)

     

    ThymeleafParser.kt

    package com.example.htmltopdf.utils
    
    import org.thymeleaf.context.Context
    import org.thymeleaf.spring5.SpringTemplateEngine
    import org.thymeleaf.templatemode.TemplateMode
    import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
    
    object ThymeleafParser {
    
        fun parseHtmlFileToString(fileName: String, variableMap: Map<String, Any?>): String {
        	// 타임리프 resolver 설정을 잡아준다.
            val templateResolver = ClassLoaderTemplateResolver()
            templateResolver.prefix = "templates/" // templates 경로 아래에 있는 파일을 읽는다
            templateResolver.suffix = ".html" // .html로 끝나는 파일을 읽는다
            templateResolver.templateMode = TemplateMode.HTML // 템플릿은 html 형식이다
    
            // 스프링 template 엔진을 thymeleafResolver를 사용하도록 설정
            val templateEngine = SpringTemplateEngine()
            templateEngine.setTemplateResolver(templateResolver)
    
            // 템플릿 엔진에서 사용될 변수를 넣어준다.
            val context = Context()
            context.setVariables(variableMap)
    
            // 지정한 html 파일과 context를 읽어 String으로 반환한다.
            return templateEngine.process(fileName, context)
        }
    }

     

    4. 변환된 String으로 pdf 파일 만들어 저장하기

    PdfGenerator.kt

    package com.example.htmltopdf.utils
    
    import com.lowagie.text.pdf.BaseFont
    import org.springframework.core.io.ClassPathResource
    import org.xhtmlrenderer.pdf.ITextRenderer
    import java.io.FileOutputStream
    
    object PdfGenerator {
    
        fun generateFromHtml(filePath: String, fileName: String, html: String): String {
        	// pdf파일을 저장할 위치
            val savedFilePath = "$filePath/$fileName.pdf"
    
            return FileOutputStream(savedFilePath).use {
                val renderer = ITextRenderer()
                renderer.setDocumentFromString(html) // document형식으로 바꿔주기 위해 html의 변환된 string을 인수로 넣는다
                renderer.layout() // pdf 모양을 잡아주는 메서드들이 실행된다(퍼사드 패턴)
                renderer.createPDF(it)
    
                // pdf 파일이 저장된 경로를 알려주기 위해 리턴
                savedFilePath
            }
        }
    }

    사실 여기까지가 html을 pdf파일로 변환해주는 기본적인 방법이다. 여기까지만 작성하고 실행해보면, pdf파일은 잘 생성된다.

     

    하지만 프로젝트 내부에 있는 이미지 파일은 pdf 파일에서는 보이지 않고, 한글로 작성된 text들은 왜 사라졌는지 의아해할 것이다.

     

    이를 해결하기 위해 조금 더 코드를 작성하고 수정해야한다.

     

    4-1. resources 경로에 있는 이미지도 같이 변환되도록 개발

    static 파일들을 따로 관리하는 서비스가 있어 url이 제공되고 접근이 가능하다면 url 경로로 자동으로 변환되지만, 모듈의 resources 경로에 있는 이미지 파일을 변환하려면 또 다른 설정이 필요하다. 

     

    설정을 해주지 않으면 이미지 경로(src)를 그냥 String으로 변환되고 아무 이미지 파일을 표시하지 않는다.

     

    flying-saucer에서 html element를 대체해줄 수 있는 factory를 특정 조건에 맞도록 커스터마이징 해주면 이를 해결할 수 있다.

    ImageReplaceElementFactory.kt

    package com.example.htmltopdf.utils
    
    import com.lowagie.text.Image
    import org.springframework.core.io.ClassPathResource
    import org.w3c.dom.Element
    import org.xhtmlrenderer.extend.ReplacedElement
    import org.xhtmlrenderer.extend.ReplacedElementFactory
    import org.xhtmlrenderer.extend.UserAgentCallback
    import org.xhtmlrenderer.layout.LayoutContext
    import org.xhtmlrenderer.pdf.ITextFSImage
    import org.xhtmlrenderer.pdf.ITextImageElement
    import org.xhtmlrenderer.render.BlockBox
    import org.xhtmlrenderer.simple.extend.FormSubmissionListener
    import java.nio.file.Files
    import java.nio.file.Path
    
    /**
     * resources 경로에 있는 이미지 파일을 PDF로 변환하기 위한 factory
     * */
    class ImageReplacedElementFactory(
        private val replacedElementFactory: ReplacedElementFactory
    ) : ReplacedElementFactory {
    
        override fun createReplacedElement(
            layoutContext: LayoutContext?,
            blockBox: BlockBox,
            userAgentCallback: UserAgentCallback?,
            cssWidth: Int,
            cssHeight: Int
        ): ReplacedElement? {
        	// element가 비어있으면 그대로 null을 반환한다
            val element = blockBox.element ?: return null
            val nodeName = element.nodeName
            val srcPath = element.getAttribute("src")
    
            // img 노드이면서 image의 경로(src)가 /image로 시작하는 노드를 필터링한다.
            return if (nodeName == "img" && srcPath.startsWith("/image")) {
    
                val fsImage = ITextFSImage(
                    Image.getInstance(
                        Files.readAllBytes(
                            Path.of( // static/image/{src경로} 이미지 파일을 읽는다
                                ClassPathResource("static${element.getAttribute("src")}").uri
                            )
                        )
                    )
                )
    
                // css의 높이, 너비가 설정되어있으면 적용한다
                if ((cssWidth != -1) || (cssHeight != -1)) {
                    fsImage.scale(cssWidth, cssHeight)
                }
    
                ITextImageElement(fsImage)
    
            } else {
                // 해당사항 없으면 고대로 반환한다
                replacedElementFactory.createReplacedElement(
                    layoutContext,
                    blockBox,
                    userAgentCallback,
                    cssWidth,
                    cssHeight
                )
            }
        }
    
        override fun remove(e: Element?) {
            replacedElementFactory.remove(e)
        }
    
        override fun setFormSubmissionListener(listener: FormSubmissionListener?) {
            replacedElementFactory.setFormSubmissionListener(listener)
        }
    
        override fun reset() {
            replacedElementFactory.reset()
        }
    }

     

    이미지 파일이나 다른 element를 체크하거나 변경하고자 하는 로직은 각자 사정에 맞춰 변경하면 된다.

     

    위에서 작성한 PdfGenerator의 generateFromHtml 함수에 작성한 ImageReplacedElementFactory를 적용한다.

     

    PdfGenerator.kt

    package com.example.htmltopdf.utils
    
    import com.lowagie.text.pdf.BaseFont
    import org.springframework.core.io.ClassPathResource
    import org.xhtmlrenderer.pdf.ITextRenderer
    import java.io.FileOutputStream
    
    object PdfGenerator {
    
        fun generateFromHtml(filePath: String, fileName: String, html: String): String {
        	// pdf파일을 저장할 위치
            val savedFilePath = "$filePath/$fileName.pdf"
    
            return FileOutputStream(savedFilePath).use {
                val renderer = ITextRenderer()
                // 커스텀한 replacedElementFactory를 사용한다
                renderer.sharedContext.replacedElementFactory =
                    ImageReplacedElementFactory(renderer.sharedContext.replacedElementFactory)
    			
                // 위에 작성한 코드들과 같음
            }
        }
    }

     

    4-2. 한글 변환이 가능하도록 추가 개발

    뭔가 된다 싶으면 막혀서 짜증났던 기억이 난다..

     

    string을 image가 되도록 바꿨는데 이번엔 한글이 안 나와서 또 찾아봤었다.

     

    다른 나라 언어까지 컨버팅 해주기 위해서는 폰트를 직접 설정해주어야 했다.

     

    원하는 폰트를 다운 받아줍시다! 난 기본만 있으면 돼서 네이버의 폰트를 제공해주는 사이트에서 나눔바른고딕을 다운 받았다.

     

    폰트를 각자 넣으려는 위치에 넣어주자. (난 resources/static/font 아래 넣었다)

     

     

    다시 PdfGenerator에서 ItextRenderer에 사용할 폰트를 추가해준다.

     

    PdfGenerator.kt

    package com.example.htmltopdf.utils
    
    import com.lowagie.text.pdf.BaseFont
    import org.springframework.core.io.ClassPathResource
    import org.xhtmlrenderer.pdf.ITextRenderer
    import java.io.FileOutputStream
    
    object PdfGenerator {
    
        fun generateFromHtml(filePath: String, fileName: String, html: String): String {
        	// pdf파일을 저장할 위치
            val savedFilePath = "$filePath/$fileName.pdf"
    
            return FileOutputStream(savedFilePath).use {
                val renderer = ITextRenderer()
                // fontResolver에 한글 폰트 추가
                renderer.fontResolver.addFont(
                    // resources 아래에 있는 폰트 경로를 입력해준다
                    ClassPathResource("/static/font/NanumBarunGothic.ttf").url.toString(),
                    BaseFont.IDENTITY_H,
                    BaseFont.EMBEDDED
                )
    			
                // 위에 작성한 코드들과 같음
            }
        }
    }

     

    마지막으로 작성한 html의 body에 font를 선언해야 한글이 나온다.

     <style>
            body {
                color: #555;
                padding: 0px;
                // 추가된 한글폰트
                font-family: "NanumBarunGothic"
            }
            <!-- 다른 css 설정 -->
     </style>        

     

    5. 결과물

    Pdf가 지정한 경로에 제대로 만들어지는지 테스트를 작성해보자.

    PdfGeneratorTest.kt

    package com.example.htmltopdf
    
    import com.example.htmltopdf.utils.PdfGenerator
    import com.example.htmltopdf.utils.ThymeleafParser
    import org.junit.jupiter.api.Test
    
    internal class PdfGeneratorTest {
    
        @Test
        fun `html을 파싱해서 pdf로 저장한다`() {
            val variableMap = mapOf(
                "name" to "펭수",
                "age" to 10,
                "job" to "EBS 연습생",
                "address" to "경기도 고양시 일산동구 한류월드로 281 (장항동) EBS 소품실, 펭숙소"
            )
    
            val html = ThymeleafParser.parseHtmlFileToString("giant-peng", variableMap)
            PdfGenerator.generateFromHtml("/Users/zorba/Desktop", "giant_peng", html)
        }
    }

     

    위의 테스트를 실행하면 Desktop 위치에 giant_peng이라는 pdf 파일이 잘 생기는걸 확인할 수 있다.

     

     

    귀여운 펭수가 있는 pdf!!

     

    작성한 예제는 깃헙 리파지토리 여기서 확인할 수 있다.

     

     

    출처: 

    stackoverrun.com/ko/q/5598718

    www.baeldung.com/thymeleaf-generate-pdf

    stackoverflow.com/questions/23173485/flying-saucer-thymeleaf-and-spring

    stackoverflow.com/questions/11477065/using-flying-saucer-to-render-images-to-pdf-in-memory

Designed by Tistory.