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(); + } + +}