diff --git a/README.md b/README.md
index 8a6428b..5ce0547 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,18 @@
# PowerPoint2Image
-Apache-POI를 사용해 슬라이드를 이미지로 변환합니다.
-Apache-POI의 문제인지 알 수 없으나, 텍스트 박스의 위치가 약간씩 조정될 수 있습니다.
+LibreOffice(soffice)로 슬라이드를 PDF로 변환한 뒤, PDFBox로 각 페이지를 이미지로 렌더링합니다.
+(이전 버전은 Apache POI 자체 렌더러를 사용했는데, 폰트 대체·그룹 도형 좌표 계산에서 오차가
+누적되어 도형/이미지/화살표 위치가 조금씩 틀어지는 문제가 있었습니다. LibreOffice는 실제
+PowerPoint와 호환되는 레이아웃 엔진이라 좌표가 훨씬 정확합니다.)
+
+## 사전 준비
+변환이 실행되는 PC/서버에 **LibreOffice가 설치되어 있어야 합니다.**
+soffice 실행 파일 경로는 아래 우선순위로 찾습니다.
+1. System Property `-Dsoffice.path=...`
+2. 환경변수 `SOFFICE_PATH`
+3. OS별 기본 설치 경로 (Windows: `C:\Program Files\LibreOffice\program\soffice.exe`, macOS: `/Applications/LibreOffice.app/Contents/MacOS/soffice`, Linux: `/usr/bin/soffice` 등)
+4. 위에서 못 찾으면 PATH에 등록된 `soffice`를 그대로 사용합니다.
+
+기본 경로와 다른 곳에 설치했다면 `new Converter(destDir, "실제 soffice 경로")` 생성자를 사용하세요.
## 사용 방법
### maven dependency에 PowerPoint2Image-1.0.0.jar 파일을 추가할 경우
diff --git a/pom.xml b/pom.xml
index 51747c7..2405462 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,25 +18,15 @@
+
- org.apache.poi
- poi
- 4.0.1
-
-
- org.apache.poi
- poi-ooxml
- 4.0.1
-
-
- org.apache.poi
- poi-ooxml-schemas
- 4.0.1
-
-
- org.apache.poi
- poi-scratchpad
- 4.0.1
+ org.apache.pdfbox
+ pdfbox
+ 2.0.29
https://github.com/seccoding/PowerPoint2Image
diff --git a/src/main/java/io/github/seccoding/ppt/Converter.java b/src/main/java/io/github/seccoding/ppt/Converter.java
index 9ddd80d..f064a8d 100644
--- a/src/main/java/io/github/seccoding/ppt/Converter.java
+++ b/src/main/java/io/github/seccoding/ppt/Converter.java
@@ -1,205 +1,173 @@
-package io.github.seccoding.ppt;
-
-import java.awt.AlphaComposite;
-import java.awt.Color;
-import java.awt.Dimension;
-import java.awt.Font;
-import java.awt.FontFormatException;
-import java.awt.Graphics2D;
-import java.awt.RenderingHints;
-import java.awt.geom.AffineTransform;
-import java.awt.geom.Rectangle2D;
-import java.awt.image.BufferedImage;
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import javax.imageio.ImageIO;
-
-import org.apache.poi.sl.draw.Drawable;
-import org.apache.poi.sl.usermodel.Slide;
-import org.apache.poi.sl.usermodel.SlideShow;
-
-import io.github.seccoding.files.FileUtils;
-
-public class Converter {
-
- private String destinationDirectory;
-
- private SlideShow slideShow;
- private Dimension pageSize;
- private List slides;
-
- private String fileName;
- private double imageSizeZoomValue;
- private AffineTransform affineTransform;
-
- private String outputFileType;
-
- private String path;
- private String outputFolder;
-
- public Converter(String destDir) {
- this.destinationDirectory = destDir;
- }
-
- public Result convert(File powerpointFile, String path, String type) {
- outputFileType = type;
-
- this.path = path + "\\";
- if (path == null || path.length() == 0) {
- this.path = "";
- }
-
- setFileName(powerpointFile);
- setSlideShow(powerpointFile);
- setSlideSize();
- setSlides();
- setZoomImageSize(2);
-
- createDirectory();
- createImages();
-
- FileUtils.copy(powerpointFile.getAbsolutePath(), outputFolder + powerpointFile.getName());
-
- Result convertResult = new Result();
- convertResult.setFileName(this.fileName);
- convertResult.setOutputFolder(outputFolder);
- convertResult.setPageSize(slides.size());
- convertResult.setOriginalFilePath(outputFolder + powerpointFile.getName());
-
- return convertResult;
- }
-
- private void setFileName(File powerpointFile) {
- fileName = powerpointFile.getName();
- fileName = fileName.substring(0, fileName.lastIndexOf("."));
- }
-
- private void setSlideShow(File powerpointFile) {
- slideShow = SlideShowFactory.getSlideShow(powerpointFile);
- }
-
- private void setSlideSize() {
- pageSize = slideShow.getPageSize();
- }
-
- private void setSlides() {
- slides = slideShow.getSlides();
- }
-
- private void setZoomImageSize(double zoom) {
- imageSizeZoomValue = zoom;
- affineTransform = new AffineTransform();
- affineTransform.setToScale(zoom, zoom);
- }
-
- private void createDirectory() {
- File dir = new File(destinationDirectory + path + fileName + "\\");
- if (!dir.exists()) {
- dir.mkdirs();
- }
-
- outputFolder = dir.getAbsolutePath() + "\\";
- }
-
- private void createImages() {
- int slidesSize = slides.size();
-
- for (int i = 0; i < slidesSize; i++) {
- BufferedImage img = createBufferedImage();
- Graphics2D graphics = setImageGraphicOptions(img);
-
- // 이미지 그리기
- slides.get(i).draw(graphics);
- writeImage(i, img);
- }
- }
-
- private BufferedImage createBufferedImage() {
- int imageWidth = (int) Math.ceil(pageSize.width * imageSizeZoomValue);
- int imageHeight = (int) Math.ceil(pageSize.height * imageSizeZoomValue);
- return new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB);
- }
-
- private Graphics2D setImageGraphicOptions(BufferedImage img) {
- Graphics2D graphics = img.createGraphics();
-
- graphics.setPaint(Color.WHITE);
- // graphics.setComposite(AlphaComposite.DstOver);
- graphics.fill(new Rectangle2D.Float(0, 0, pageSize.width, pageSize.height));
- graphics.setTransform(affineTransform);
-
- graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
- graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
- graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
- graphics.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
-
- return graphics;
- }
-
- private void writeImage(int index, BufferedImage img) {
- // 파일로 저장
- FileOutputStream out = null;
- try {
- out = new FileOutputStream(outputFolder + (index + 1) + "." + outputFileType);
- ImageIO.write(img, outputFileType, out);
- } catch (FileNotFoundException e) {
- throw new RuntimeException(e.getMessage(), e);
- } catch (IOException e) {
- throw new RuntimeException(e.getMessage(), e);
- } finally {
- if (out != null) {
- try {
- out.close();
- } catch (IOException e) {
- }
- }
- }
-
- }
-
- public static class Result {
- private String originalFilePath;
- private String outputFolder;
- private String fileName;
- private int pageSize;
-
- public String getOriginalFilePath() {
- return originalFilePath;
- }
-
- public void setOriginalFilePath(String originalFilePath) {
- this.originalFilePath = originalFilePath;
- }
-
- public String getOutputFolder() {
- return outputFolder;
- }
-
- public void setOutputFolder(String outputFolder) {
- this.outputFolder = outputFolder;
- }
-
- public String getFileName() {
- return fileName;
- }
-
- public void setFileName(String fileName) {
- this.fileName = fileName;
- }
-
- public int getPageSize() {
- return pageSize;
- }
-
- public void setPageSize(int pageSize) {
- this.pageSize = pageSize;
- }
- }
-
-}
+package io.github.seccoding.ppt;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+import javax.imageio.ImageIO;
+
+import io.github.seccoding.files.FileUtils;
+
+/**
+ * ppt/pptx의 각 슬라이드를 이미지로 변환한다.
+ *
+ * 내부적으로 LibreOffice(soffice)를 headless로 실행해 PDF로 변환한 뒤,
+ * 그 PDF의 각 페이지를 PDFBox로 렌더링하는 방식을 사용한다.
+ * (이전 버전은 Apache POI의 자체 렌더러를 사용했으나, 폰트 대체·그룹 도형 변환 계산에서
+ * 오차가 누적되어 도형/이미지/화살표 좌표가 조금씩 틀어지는 문제가 있었다.
+ * LibreOffice는 PowerPoint와 실제로 호환되는 레이아웃 엔진이므로 훨씬 정확하다.)
+ *
+ * 사전 준비: 이 클래스를 사용하려면 변환이 실행되는 PC/서버에 LibreOffice가 설치되어 있어야 한다.
+ * soffice 실행 경로는 System Property "soffice.path" 또는 환경변수 SOFFICE_PATH로 지정할 수 있고,
+ * 지정하지 않으면 Windows/Linux/macOS의 일반적인 설치 경로를 자동으로 찾는다. (SofficeLocator 참고)
+ *
+ * @author Minchang Jang (mcjang1116@gmail.com)
+ */
+public class Converter {
+
+ /** 96dpi를 배율 1로 취급한다. (기존 POI 버전의 zoom=2 결과물과 해상도를 맞추기 위한 기준) */
+ private static final float BASE_DPI = 96f;
+
+ private String destinationDirectory;
+
+ private final SofficePdfConverter sofficePdfConverter;
+ private final PdfPageRenderer pdfPageRenderer;
+
+ private String fileName;
+ private float dpi;
+ private String outputFileType;
+
+ private String path;
+ private String outputFolder;
+
+ public Converter(String destDir) {
+ this(destDir, new SofficePdfConverter(), new PdfPageRenderer());
+ }
+
+ /** soffice 경로를 직접 지정하고 싶을 때 사용한다. */
+ public Converter(String destDir, String sofficePath) {
+ this(destDir, new SofficePdfConverter(sofficePath), new PdfPageRenderer());
+ }
+
+ Converter(String destDir, SofficePdfConverter sofficePdfConverter, PdfPageRenderer pdfPageRenderer) {
+ this.destinationDirectory = destDir;
+ this.sofficePdfConverter = sofficePdfConverter;
+ this.pdfPageRenderer = pdfPageRenderer;
+ }
+
+ public Result convert(File powerpointFile, String path, String type) {
+ return convert(powerpointFile, path, type, 2);
+ }
+
+ /**
+ * @param zoom 이미지 해상도 배율. 1이면 96dpi, 2면 192dpi(기존 POI 버전의 zoom=2와 동급 해상도).
+ */
+ public Result convert(File powerpointFile, String path, String type, double zoom) {
+ outputFileType = type;
+ dpi = (float) (BASE_DPI * zoom);
+
+ this.path = path + "\\";
+ if (path == null || path.length() == 0) {
+ this.path = "";
+ }
+
+ setFileName(powerpointFile);
+ createDirectory();
+
+ File pdfFile = sofficePdfConverter.convertToPdf(powerpointFile, new File(outputFolder));
+ BufferedImage[] images = pdfPageRenderer.render(pdfFile, dpi);
+ writeImages(images);
+ pdfFile.delete();
+
+ FileUtils.copy(powerpointFile.getAbsolutePath(), outputFolder + powerpointFile.getName());
+
+ Result convertResult = new Result();
+ convertResult.setFileName(this.fileName);
+ convertResult.setOutputFolder(outputFolder);
+ convertResult.setPageSize(images.length);
+ convertResult.setOriginalFilePath(outputFolder + powerpointFile.getName());
+
+ return convertResult;
+ }
+
+ private void setFileName(File powerpointFile) {
+ fileName = powerpointFile.getName();
+ fileName = fileName.substring(0, fileName.lastIndexOf("."));
+ }
+
+ private void createDirectory() {
+ File dir = new File(destinationDirectory + path + fileName + "\\");
+ if (!dir.exists()) {
+ dir.mkdirs();
+ }
+
+ outputFolder = dir.getAbsolutePath() + "\\";
+ }
+
+ private void writeImages(BufferedImage[] images) {
+ for (int i = 0; i < images.length; i++) {
+ writeImage(i, images[i]);
+ }
+ }
+
+ private void writeImage(int index, BufferedImage img) {
+ FileOutputStream out = null;
+ try {
+ out = new FileOutputStream(outputFolder + (index + 1) + "." + outputFileType);
+ ImageIO.write(img, outputFileType, out);
+ } catch (FileNotFoundException e) {
+ throw new RuntimeException(e.getMessage(), e);
+ } catch (IOException e) {
+ throw new RuntimeException(e.getMessage(), e);
+ } finally {
+ if (out != null) {
+ try {
+ out.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ }
+
+ public static class Result {
+ private String originalFilePath;
+ private String outputFolder;
+ private String fileName;
+ private int pageSize;
+
+ public String getOriginalFilePath() {
+ return originalFilePath;
+ }
+
+ public void setOriginalFilePath(String originalFilePath) {
+ this.originalFilePath = originalFilePath;
+ }
+
+ public String getOutputFolder() {
+ return outputFolder;
+ }
+
+ public void setOutputFolder(String outputFolder) {
+ this.outputFolder = outputFolder;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public void setFileName(String fileName) {
+ this.fileName = fileName;
+ }
+
+ public int getPageSize() {
+ return pageSize;
+ }
+
+ public void setPageSize(int pageSize) {
+ this.pageSize = pageSize;
+ }
+ }
+
+}
diff --git a/src/main/java/io/github/seccoding/ppt/PPT.java b/src/main/java/io/github/seccoding/ppt/PPT.java
deleted file mode 100644
index fef4b6f..0000000
--- a/src/main/java/io/github/seccoding/ppt/PPT.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package io.github.seccoding.ppt;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-
-import org.apache.poi.sl.usermodel.SlideShow;
-import org.apache.poi.hslf.usermodel.HSLFSlideShow;
-
-public class PPT {
-
- public SlideShow getSlideShow(File pptFile) {
- try {
- return new HSLFSlideShow(new FileInputStream(pptFile));
- } catch (IOException e) {
- throw new RuntimeException(e.getMessage(), e);
- }
- }
-
-}
diff --git a/src/main/java/io/github/seccoding/ppt/PPTX.java b/src/main/java/io/github/seccoding/ppt/PPTX.java
deleted file mode 100644
index ddb42b9..0000000
--- a/src/main/java/io/github/seccoding/ppt/PPTX.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package io.github.seccoding.ppt;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-
-import org.apache.poi.sl.usermodel.SlideShow;
-import org.apache.poi.xslf.usermodel.XMLSlideShow;
-
-public class PPTX {
-
- public SlideShow getSlideShow(File pptFile) {
- try {
- return new XMLSlideShow(new FileInputStream(pptFile));
- } catch (IOException e) {
- throw new RuntimeException(e.getMessage(), e);
- }
- }
-
-}
diff --git a/src/main/java/io/github/seccoding/ppt/PdfPageRenderer.java b/src/main/java/io/github/seccoding/ppt/PdfPageRenderer.java
new file mode 100644
index 0000000..ae8a8fb
--- /dev/null
+++ b/src/main/java/io/github/seccoding/ppt/PdfPageRenderer.java
@@ -0,0 +1,65 @@
+package io.github.seccoding.ppt;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.rendering.PDFRenderer;
+
+/**
+ * PDF의 각 페이지(=원래 슬라이드 한 장)를 BufferedImage로 렌더링한다.
+ * PDF는 LibreOffice가 만든 결과물이므로, 렌더링 좌표는 LibreOffice의 실제 레이아웃 엔진을 그대로 따른다.
+ *
+ * @author Minchang Jang (mcjang1116@gmail.com)
+ */
+public class PdfPageRenderer {
+
+ /**
+ * @param pdfFile 렌더링할 PDF 파일
+ * @param dpi 해상도. 96이 화면 기본 배율(zoom 1)이고, 값이 커질수록 더 선명한 고해상도 이미지가 된다.
+ * @return 페이지 순서대로 담긴 이미지 배열 (index 0 = 1번 슬라이드)
+ */
+ public BufferedImage[] render(File pdfFile, float dpi) {
+ PDDocument document = null;
+ try {
+ document = PDDocument.load(pdfFile);
+ PDFRenderer renderer = new PDFRenderer(document);
+
+ int pageCount = document.getNumberOfPages();
+ BufferedImage[] images = new BufferedImage[pageCount];
+ for (int i = 0; i < pageCount; i++) {
+ images[i] = renderer.renderImageWithDPI(i, dpi);
+ }
+ return images;
+ } catch (IOException e) {
+ throw new RuntimeException("PDF 렌더링에 실패했습니다: " + pdfFile.getAbsolutePath(), e);
+ } finally {
+ closeQuietly(document);
+ }
+ }
+
+ public int countPages(File pdfFile) {
+ PDDocument document = null;
+ try {
+ document = PDDocument.load(pdfFile);
+ return document.getNumberOfPages();
+ } catch (IOException e) {
+ throw new RuntimeException("PDF를 여는 데 실패했습니다: " + pdfFile.getAbsolutePath(), e);
+ } finally {
+ closeQuietly(document);
+ }
+ }
+
+ private void closeQuietly(PDDocument document) {
+ if (document == null) {
+ return;
+ }
+ try {
+ document.close();
+ } catch (IOException e) {
+ // 닫기 실패는 무시한다.
+ }
+ }
+
+}
diff --git a/src/main/java/io/github/seccoding/ppt/SlideShowFactory.java b/src/main/java/io/github/seccoding/ppt/SlideShowFactory.java
deleted file mode 100644
index a24837e..0000000
--- a/src/main/java/io/github/seccoding/ppt/SlideShowFactory.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package io.github.seccoding.ppt;
-
-import java.io.File;
-
-import org.apache.poi.sl.usermodel.SlideShow;
-
-public class SlideShowFactory {
-
- public static SlideShow getSlideShow(File pptFile) {
-
- String fileName = pptFile.getName();
- if ( fileName.toLowerCase().endsWith(".ppt") ) {
- return new PPT().getSlideShow(pptFile);
- }
- else if ( fileName.toLowerCase().endsWith(".pptx") ) {
- return new PPTX().getSlideShow(pptFile);
- }
-
- throw new RuntimeException("PowerPoint 파일이 아닙니다.");
-
- }
-
-}
diff --git a/src/main/java/io/github/seccoding/ppt/SofficeLocator.java b/src/main/java/io/github/seccoding/ppt/SofficeLocator.java
new file mode 100644
index 0000000..a169c36
--- /dev/null
+++ b/src/main/java/io/github/seccoding/ppt/SofficeLocator.java
@@ -0,0 +1,87 @@
+package io.github.seccoding.ppt;
+
+import java.io.File;
+
+/**
+ * LibreOffice(soffice) 실행 파일의 경로를 찾는다.
+ *
+ * 우선순위
+ * 1. System Property "soffice.path" (예: -Dsoffice.path=... )
+ * 2. 환경변수 SOFFICE_PATH
+ * 3. OS별 기본 설치 경로 (Windows / Linux / macOS)
+ * 4. PATH 상의 "soffice" (마지막 fallback, 존재 여부를 검증하지 않고 그대로 사용)
+ *
+ * @author Minchang Jang (mcjang1116@gmail.com)
+ */
+public class SofficeLocator {
+
+ private static final String PROPERTY_KEY = "soffice.path";
+ private static final String ENV_KEY = "SOFFICE_PATH";
+
+ private static final String[] WINDOWS_CANDIDATES = {
+ "C:\\Program Files\\LibreOffice\\program\\soffice.exe",
+ "C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe"
+ };
+
+ private static final String[] LINUX_CANDIDATES = {
+ "/usr/bin/soffice",
+ "/usr/local/bin/soffice",
+ "/opt/libreoffice/program/soffice"
+ };
+
+ private static final String[] MAC_CANDIDATES = {
+ "/Applications/LibreOffice.app/Contents/MacOS/soffice"
+ };
+
+ public static String resolve() {
+
+ String fromProperty = System.getProperty(PROPERTY_KEY);
+ if (isUsablePath(fromProperty)) {
+ return fromProperty;
+ }
+
+ String fromEnv = System.getenv(ENV_KEY);
+ if (isUsablePath(fromEnv)) {
+ return fromEnv;
+ }
+
+ for (String candidate : candidatesForCurrentOs()) {
+ if (isUsablePath(candidate)) {
+ return candidate;
+ }
+ }
+
+ // PATH 위에 있을 것으로 기대하고 그대로 반환한다. (Linux 서버에서 흔한 설치 형태)
+ return isWindows() ? "soffice.exe" : "soffice";
+ }
+
+ private static String[] candidatesForCurrentOs() {
+ if (isWindows()) {
+ return WINDOWS_CANDIDATES;
+ }
+ if (isMac()) {
+ return MAC_CANDIDATES;
+ }
+ return LINUX_CANDIDATES;
+ }
+
+ private static boolean isUsablePath(String path) {
+ if (path == null || path.trim().length() == 0) {
+ return false;
+ }
+ return new File(path).exists();
+ }
+
+ private static boolean isWindows() {
+ return osName().contains("win");
+ }
+
+ private static boolean isMac() {
+ return osName().contains("mac");
+ }
+
+ private static String osName() {
+ return System.getProperty("os.name", "").toLowerCase();
+ }
+
+}
diff --git a/src/main/java/io/github/seccoding/ppt/SofficePdfConverter.java b/src/main/java/io/github/seccoding/ppt/SofficePdfConverter.java
new file mode 100644
index 0000000..1295ce6
--- /dev/null
+++ b/src/main/java/io/github/seccoding/ppt/SofficePdfConverter.java
@@ -0,0 +1,150 @@
+package io.github.seccoding.ppt;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * LibreOffice(soffice)를 headless 모드로 실행해 ppt/pptx를 PDF로 변환한다.
+ *
+ * POI의 자체 렌더러(Slide#draw)는 폰트 메트릭·그룹 도형 변환 계산에서 오차가 누적되어
+ * 도형/이미지/화살표 좌표가 조금씩 틀어지는 문제가 있었다. LibreOffice는 PowerPoint와
+ * 호환되는 실제 레이아웃 엔진으로 렌더링하므로 좌표가 훨씬 정확하다.
+ *
+ * @author Minchang Jang (mcjang1116@gmail.com)
+ */
+public class SofficePdfConverter {
+
+ /** soffice 프로세스가 끝나지 않고 멈춰버리는 경우를 대비한 최대 대기 시간(초) */
+ private static final long TIMEOUT_SECONDS = 120;
+
+ private final String sofficePath;
+
+ public SofficePdfConverter() {
+ this(SofficeLocator.resolve());
+ }
+
+ public SofficePdfConverter(String sofficePath) {
+ this.sofficePath = sofficePath;
+ }
+
+ /**
+ * powerpointFile을 PDF로 변환해 outputDir 안에 생성하고, 생성된 PDF 파일을 반환한다.
+ */
+ public File convertToPdf(File powerpointFile, File outputDir) {
+
+ Path userInstallationDir = createIsolatedProfileDirectory();
+
+ try {
+ runSoffice(powerpointFile, outputDir, userInstallationDir);
+ return locateConvertedPdf(powerpointFile, outputDir);
+ } finally {
+ deleteQuietly(userInstallationDir.toFile());
+ }
+ }
+
+ private void runSoffice(File powerpointFile, File outputDir, Path userInstallationDir) {
+
+ List command = new ArrayList();
+ command.add(sofficePath);
+ command.add("--headless");
+ command.add("--norestore");
+ command.add("--convert-to");
+ command.add("pdf");
+ command.add("--outdir");
+ command.add(outputDir.getAbsolutePath());
+ // 동시에 여러 변환이 실행돼도 서로 프로필을 잠그지 않도록 매 실행마다 격리된 프로필을 사용한다.
+ command.add("-env:UserInstallation=file:///" + toSlashPath(userInstallationDir));
+ command.add(powerpointFile.getAbsolutePath());
+
+ try {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.redirectErrorStream(true);
+ Process process = builder.start();
+
+ // 출력 버퍼가 가득 차 프로세스가 멈추는 것을 막기 위해 표준출력을 계속 읽어서 버린다.
+ drainQuietly(process);
+
+ boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ throw new RuntimeException("soffice 변환이 " + TIMEOUT_SECONDS + "초 안에 끝나지 않았습니다: "
+ + powerpointFile.getAbsolutePath());
+ }
+ if (process.exitValue() != 0) {
+ throw new RuntimeException("soffice 변환이 실패했습니다. exitCode=" + process.exitValue()
+ + ", 실행 경로=" + sofficePath
+ + " (LibreOffice가 설치되어 있는지, soffice 경로가 맞는지 확인하세요)");
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("soffice 실행에 실패했습니다. 실행 경로=" + sofficePath
+ + " (LibreOffice가 설치되어 있는지 확인하세요)", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("soffice 변환이 중단되었습니다.", e);
+ }
+ }
+
+ private File locateConvertedPdf(File powerpointFile, File outputDir) {
+ String baseName = powerpointFile.getName();
+ int dot = baseName.lastIndexOf('.');
+ if (dot > 0) {
+ baseName = baseName.substring(0, dot);
+ }
+
+ File pdf = new File(outputDir, baseName + ".pdf");
+ if (!pdf.exists()) {
+ throw new RuntimeException("PDF 변환 결과 파일을 찾을 수 없습니다: " + pdf.getAbsolutePath());
+ }
+ return pdf;
+ }
+
+ private Path createIsolatedProfileDirectory() {
+ try {
+ return Files.createTempDirectory("soffice-profile-");
+ } catch (IOException e) {
+ throw new RuntimeException("soffice 임시 프로필 디렉토리 생성에 실패했습니다.", e);
+ }
+ }
+
+ private String toSlashPath(Path path) {
+ // Windows에서도 file:/// URI는 슬래시(/)를 사용해야 한다.
+ return path.toAbsolutePath().toString().replace('\\', '/');
+ }
+
+ private void drainQuietly(final Process process) {
+ Thread drainThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ byte[] buffer = new byte[4096];
+ while (process.getInputStream().read(buffer) != -1) {
+ // 읽어서 버린다.
+ }
+ } catch (IOException e) {
+ // 프로세스가 종료되며 스트림이 닫히는 정상적인 상황도 포함되므로 무시한다.
+ }
+ }
+ });
+ drainThread.setDaemon(true);
+ drainThread.start();
+ }
+
+ private void deleteQuietly(File file) {
+ if (file == null || !file.exists()) {
+ return;
+ }
+ File[] children = file.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ deleteQuietly(child);
+ }
+ }
+ file.delete();
+ }
+
+}