diff --git a/server/src/main/java/cn/keking/service/CadToPdfService.java b/server/src/main/java/cn/keking/service/CadToPdfService.java new file mode 100644 index 00000000..79f2bb51 --- /dev/null +++ b/server/src/main/java/cn/keking/service/CadToPdfService.java @@ -0,0 +1,503 @@ +package cn.keking.service; + +import cn.keking.config.ConfigConstants; +import cn.keking.model.FileAttribute; +import cn.keking.utils.RemoveSvgAdSimple; +import com.aspose.cad.*; +import com.aspose.cad.fileformats.cad.CadDrawTypeMode; +import com.aspose.cad.fileformats.tiff.enums.TiffExpectedFormat; +import com.aspose.cad.imageoptions.*; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.concurrent.*; + +/** + * CAD文件转换服务 + * @author yudian-it + */ +@Component +public class CadToPdfService { + private static final Logger logger = LoggerFactory.getLogger(CadToPdfService.class); + + /** + * CAD转换线程池 + */ + private ExecutorService pool; + + /** + * 初始化线程池 + */ + @PostConstruct + public void init() { + try { + int threadCount = getThreadPoolSize(); + + // 使用 ThreadPoolExecutor 而不是 FixedThreadPool,便于控制队列和拒绝策略 + int queueCapacity = getQueueCapacity(); + // 核心线程数 + // 最大线程数(与核心线程数相同,实现固定大小) + // 空闲线程存活时间(秒) + // 有界队列,避免内存溢出 + // 拒绝策略:由调用线程执行 + /** + * 线程池监控 + */ + ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( + threadCount, // 核心线程数 + threadCount, // 最大线程数(与核心线程数相同,实现固定大小) + 60L, // 空闲线程存活时间(秒) + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(queueCapacity), // 有界队列,避免内存溢出 + // 修改线程工厂部分 + r -> { + Thread t = new Thread(r); + // 使用时间戳和随机数生成唯一标识 + String threadId = System.currentTimeMillis() + "-" + + ThreadLocalRandom.current().nextInt(1000); + t.setName("cad-convert-pool-" + threadId); + t.setUncaughtExceptionHandler((thread, throwable) -> + logger.error("CAD转换线程未捕获异常: {}", thread.getName(), throwable)); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:由调用线程执行 + ); + + // 允许核心线程超时回收 + threadPoolExecutor.allowCoreThreadTimeOut(true); + + pool = threadPoolExecutor; + + logger.info("CAD转换线程池初始化完成,线程数: {}, 队列容量: {}", + threadCount, queueCapacity); + + } catch (Exception e) { + logger.error("CAD转换线程池初始化失败", e); + // 提供默认值 + int defaultThreads = Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + pool = Executors.newFixedThreadPool(defaultThreads); + logger.warn("使用默认线程池配置,线程数: {}", defaultThreads); + } + } + + /** + * 获取线程池大小配置 + */ + private int getThreadPoolSize() { + try { + int threadCount = ConfigConstants.getCadThread(); + if (threadCount <= 0) { + threadCount = Runtime.getRuntime().availableProcessors(); + logger.warn("CAD线程数配置无效,使用CPU核心数: {}", threadCount); + } + // 限制最大线程数,避免资源耗尽 + int maxThreads = Runtime.getRuntime().availableProcessors() * 2; + if (threadCount > maxThreads) { + logger.warn("CAD线程数配置过大({}),限制为: {}", threadCount, maxThreads); + threadCount = maxThreads; + } + return threadCount; + } catch (Exception e) { + logger.error("获取CAD线程数配置失败", e); + return Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + } + } + + /** + * 获取队列容量 + */ + private int getQueueCapacity() { + // 根据线程数动态计算队列容量 + int threadCount = getThreadPoolSize(); + return threadCount * 10; // 每个线程10个待处理任务 + } + + /** + * 优雅关闭线程池 + */ + @PreDestroy + public void shutdown() { + if (pool != null && !pool.isShutdown()) { + gracefulShutdown(pool, getShutdownTimeout()); + } + } + + /** + * 获取关闭超时时间 + */ + private long getShutdownTimeout() { + try { + return Long.parseLong(ConfigConstants.getCadTimeout()); + } catch (Exception e) { + logger.warn("获取CAD关闭超时时间失败,使用默认值60秒", e); + return 60L; + } + } + + /** + * 通用线程池优雅关闭方法 + */ + private void gracefulShutdown(ExecutorService executor, long timeoutSeconds) { + logger.info("开始关闭{}...", "CAD转换线程池"); + + // 停止接收新任务 + executor.shutdown(); + + try { + // 等待现有任务完成 + if (!executor.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { + logger.warn("{}超时未关闭,尝试强制关闭...", "CAD转换线程池"); + + // 取消所有未完成的任务 + executor.shutdownNow(); + + // 再次等待 + if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { + logger.error("{}无法正常关闭,可能存在挂起的任务", "CAD转换线程池"); + } else { + logger.info("{}已强制关闭", "CAD转换线程池"); + } + } else { + logger.info("{}已正常关闭", "CAD转换线程池"); + } + } catch (InterruptedException e) { + logger.error("{}关闭时被中断", "CAD转换线程池", e); + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + /** + * CAD文件转换 + * + * @param inputFilePath 输入CAD文件路径 + * @param outputFilePath 输出文件路径 + * @param cadPreviewType 预览类型 (svg/pdf/tif) + * @param fileAttribute 文件属性 + * @return 转换成功返回true,失败返回false + */ + public boolean cadToPdf(String inputFilePath, String outputFilePath, + String cadPreviewType, FileAttribute fileAttribute) { + + final InterruptionTokenSource source = new InterruptionTokenSource(); + + try { + // 验证输入参数 + if (!validateInputParameters(inputFilePath, outputFilePath, cadPreviewType)) { + return false; + } + + // 创建输出选项 + final SvgOptions svgOptions = new SvgOptions(); + final PdfOptions pdfOptions = new PdfOptions(); + final TiffOptions tiffOptions = new TiffOptions(TiffExpectedFormat.TiffJpegRgb); + + // 创建输出目录 + createOutputDirectoryIfNeeded(outputFilePath, fileAttribute.isCompressFile()); + + File outputFile = new File(outputFilePath); + + // 加载并转换CAD文件 + LoadOptions loadOptions = createLoadOptions(); + try (Image cadImage = Image.load(inputFilePath, loadOptions)) { + + CadRasterizationOptions rasterizationOptions = createRasterizationOptions(cadImage); + configureOutputOptions(cadPreviewType, rasterizationOptions, source, + svgOptions, pdfOptions, tiffOptions); + + Callable conversionTask = createConversionTask(cadPreviewType, outputFile, + cadImage, source, + svgOptions, pdfOptions, tiffOptions); + + Future result = pool.submit(conversionTask); + + return executeWithTimeout(result, source, cadImage, inputFilePath); + } + } catch (Exception e) { + logger.error("CAD文件转换失败: {}", inputFilePath, e); + return false; + } finally { + // 确保资源释放 + try { + source.dispose(); + } catch (Exception e) { + logger.warn("释放CAD中断令牌资源失败", e); + } + + // SVG文件后处理 + if ("svg".equals(cadPreviewType)) { + postProcessSvgFile(outputFilePath); + } + } + } + + /** + * 验证输入参数 + */ + private boolean validateInputParameters(String inputFilePath, String outputFilePath, + String cadPreviewType) { + if (inputFilePath == null || inputFilePath.trim().isEmpty()) { + logger.error("输入文件路径为空"); + return false; + } + + File inputFile = new File(inputFilePath); + if (!inputFile.exists()) { + logger.error("输入文件不存在: {}", inputFilePath); + return false; + } + + if (outputFilePath == null || outputFilePath.trim().isEmpty()) { + logger.error("输出文件路径为空"); + return false; + } + + if (!isSupportedPreviewType(cadPreviewType)) { + logger.error("不支持的预览类型: {}", cadPreviewType); + return false; + } + + return true; + } + + /** + * 检查是否支持的预览类型 + */ + private boolean isSupportedPreviewType(String previewType) { + return "svg".equalsIgnoreCase(previewType) || + "pdf".equalsIgnoreCase(previewType) || + "tif".equalsIgnoreCase(previewType) || + "tiff".equalsIgnoreCase(previewType); + } + + /** + * 创建输出目录 + */ + private void createOutputDirectoryIfNeeded(String outputFilePath, boolean isCompressFile) { + if (!isCompressFile) { + return; + } + + File outputFile = new File(outputFilePath); + File parentDir = outputFile.getParentFile(); + + if (parentDir != null && !parentDir.exists()) { + boolean created = parentDir.mkdirs(); + if (created) { + logger.debug("创建输出目录: {}", parentDir.getAbsolutePath()); + } else { + logger.warn("无法创建输出目录: {}", parentDir.getAbsolutePath()); + } + } + } + + /** + * 创建加载选项 + */ + private LoadOptions createLoadOptions() { + LoadOptions opts = new LoadOptions(); + opts.setSpecifiedEncoding(CodePages.SimpChinese); + return opts; + } + + /** + * 创建光栅化选项 + */ + private CadRasterizationOptions createRasterizationOptions(Image cadImage) { + RasterizationQuality quality = new RasterizationQuality(); + RasterizationQualityValue highQuality = RasterizationQualityValue.High; + + quality.setArc(highQuality); + quality.setHatch(highQuality); + quality.setText(highQuality); + quality.setOle(highQuality); + quality.setObjectsPrecision(highQuality); + quality.setTextThicknessNormalization(true); + + CadRasterizationOptions options = new CadRasterizationOptions(); + options.setBackgroundColor(Color.getWhite()); + options.setPageWidth(cadImage.getWidth()); + options.setPageHeight(cadImage.getHeight()); + options.setUnitType(cadImage.getUnitType()); + options.setAutomaticLayoutsScaling(false); + options.setNoScaling(false); + options.setQuality(quality); + options.setDrawType(CadDrawTypeMode.UseObjectColor); + options.setExportAllLayoutContent(true); + options.setVisibilityMode(VisibilityMode.AsScreen); + + return options; + } + + /** + * 配置输出选项 + */ + private void configureOutputOptions(String previewType, + CadRasterizationOptions rasterizationOptions, + InterruptionTokenSource source, + SvgOptions svgOptions, + PdfOptions pdfOptions, + TiffOptions tiffOptions) { + + String type = previewType.toLowerCase(); + switch (type) { + case "svg": + svgOptions.setVectorRasterizationOptions(rasterizationOptions); + svgOptions.setInterruptionToken(source.getToken()); + break; + case "pdf": + pdfOptions.setVectorRasterizationOptions(rasterizationOptions); + pdfOptions.setInterruptionToken(source.getToken()); + break; + case "tif": + case "tiff": + tiffOptions.setVectorRasterizationOptions(rasterizationOptions); + tiffOptions.setInterruptionToken(source.getToken()); + break; + default: + throw new IllegalArgumentException("不支持的预览类型: " + previewType); + } + } + + /** + * 创建转换任务 + */ + private Callable createConversionTask(String previewType, + File outputFile, + Image cadImage, + InterruptionTokenSource source, + SvgOptions svgOptions, + PdfOptions pdfOptions, + TiffOptions tiffOptions) { + + return () -> { + try (OutputStream outputStream = new FileOutputStream(outputFile)) { + String type = previewType.toLowerCase(); + + switch (type) { + case "svg": + cadImage.save(outputStream, svgOptions); + break; + case "pdf": + cadImage.save(outputStream, pdfOptions); + break; + case "tif": + case "tiff": + cadImage.save(outputStream, tiffOptions); + break; + default: + throw new IllegalStateException("不支持的预览类型: " + previewType); + } + + logger.debug("CAD文件转换成功: {} -> {}", cadImage, outputFile.getPath()); + return true; + + } catch (IOException e) { + logger.error("保存转换结果失败: {}", outputFile.getPath(), e); + throw e; + } catch (Exception e) { + logger.error("CAD转换过程异常", e); + throw e; + } + }; + } + + /** + * 执行带超时的转换 + */ + private boolean executeWithTimeout(Future result, + InterruptionTokenSource source, + Image cadImage, + String inputFilePath) { + long timeout = getConversionTimeout(); + + try { + Boolean success = result.get(timeout, TimeUnit.SECONDS); + return Boolean.TRUE.equals(success); + + } catch (TimeoutException e) { + logger.error("CAD转换超时,文件: {},超时时间: {}秒", inputFilePath, timeout, e); + handleTimeout(result, source); + return false; + + } catch (InterruptedException e) { + logger.error("CAD转换被中断,文件: {}", inputFilePath, e); + Thread.currentThread().interrupt(); + handleInterruption(result); + return false; + + } catch (ExecutionException e) { + logger.error("CAD转换执行异常,文件: {}", inputFilePath, e); + return false; + + } catch (Exception e) { + logger.error("CAD转换未知异常,文件: {}", inputFilePath, e); + return false; + } + } + + /** + * 获取转换超时时间 + */ + private long getConversionTimeout() { + try { + long timeout = Long.parseLong(ConfigConstants.getCadTimeout()); + if (timeout <= 0) { + timeout = 300L; // 默认5分钟 + logger.warn("CAD转换超时时间配置无效,使用默认值: {}秒", timeout); + } + return timeout; + } catch (NumberFormatException e) { + logger.warn("解析CAD转换超时时间失败,使用默认值300秒", e); + return 300L; + } + } + + /** + * 处理超时情况 + */ + private void handleTimeout(Future result, InterruptionTokenSource source) { + try { + source.interrupt(); + } catch (Exception e) { + logger.warn("中断CAD转换过程失败", e); + } + + try { + boolean cancelled = result.cancel(true); + logger.debug("超时任务取消结果: {}", cancelled ? "成功" : "失败"); + } catch (Exception e) { + logger.warn("取消超时任务失败", e); + } + } + + /** + * 处理中断情况 + */ + private void handleInterruption(Future result) { + try { + result.cancel(true); + } catch (Exception e) { + logger.warn("取消被中断的任务失败", e); + } + } + + /** + * SVG文件后处理 + */ + private void postProcessSvgFile(String outputFilePath) { + try { + RemoveSvgAdSimple.removeSvgAdFromFile(outputFilePath); + logger.debug("SVG文件后处理完成: {}", outputFilePath); + } catch (Exception e) { + logger.warn("SVG文件后处理失败: {}", outputFilePath, e); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/PdfToJpgService.java b/server/src/main/java/cn/keking/service/PdfToJpgService.java new file mode 100644 index 00000000..f07a3f33 --- /dev/null +++ b/server/src/main/java/cn/keking/service/PdfToJpgService.java @@ -0,0 +1,399 @@ +package cn.keking.service; + +import cn.keking.config.ConfigConstants; +import cn.keking.model.FileAttribute; +import cn.keking.service.cache.NotResourceCache; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.tools.imageio.ImageIOUtil; +import org.apache.poi.EncryptedDocumentException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @author yudian-it + */ +@Component +public class PdfToJpgService { + private final FileHandlerService fileHandlerService; + + // PDF转换专用线程池 + private ExecutorService pdfConversionPool; + private ThreadPoolExecutor pdfThreadPoolExecutor; + + private static final Logger logger = LoggerFactory.getLogger(PdfToJpgService.class); + private static final String PDF_PASSWORD_MSG = "password"; + private static final String PDF2JPG_IMAGE_FORMAT = ".jpg"; + + // 最大并行页数阈值 + private static final int MAX_PARALLEL_PAGES = 20; + + public PdfToJpgService(FileHandlerService fileHandlerService) { + this.fileHandlerService = fileHandlerService; + } + + @PostConstruct + public void init() { + try { + int threadCount = getPdfThreadPoolSize(); + int queueCapacity = threadCount * 10; + + AtomicInteger threadNum = new AtomicInteger(1); + pdfThreadPoolExecutor = new ThreadPoolExecutor( + threadCount, + threadCount, + 60L, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(queueCapacity), + r -> { + Thread t = new Thread(r); + t.setName("pdf-convert-pool-" + threadNum.getAndIncrement()); + t.setUncaughtExceptionHandler((thread, throwable) -> + logger.error("PDF转换线程未捕获异常: {}", thread.getName(), throwable)); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy() + ); + + pdfThreadPoolExecutor.allowCoreThreadTimeOut(true); + pdfConversionPool = pdfThreadPoolExecutor; + + logger.info("PDF转换线程池初始化完成,线程数: {}, 队列容量: {}", + threadCount, queueCapacity); + + } catch (Exception e) { + logger.error("PDF转换线程池初始化失败", e); + int defaultThreads = Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + pdfConversionPool = Executors.newFixedThreadPool(defaultThreads); + logger.warn("使用默认PDF线程池配置,线程数: {}", defaultThreads); + } + } + + private int getPdfThreadPoolSize() { + try { + String pdfThreadConfig = System.getProperty("pdf.thread.count"); + int threadCount; + if (pdfThreadConfig != null) { + threadCount = Integer.parseInt(pdfThreadConfig); + } else { + threadCount = Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + } + + if (threadCount <= 0) { + threadCount = Runtime.getRuntime().availableProcessors(); + logger.warn("PDF线程数配置无效,使用CPU核心数: {}", threadCount); + } + + int maxThreads = Runtime.getRuntime().availableProcessors() * 2; + if (threadCount > maxThreads) { + logger.warn("PDF线程数配置过大({}),限制为: {}", threadCount, maxThreads); + threadCount = maxThreads; + } + return threadCount; + } catch (Exception e) { + logger.error("获取PDF线程数配置失败", e); + return Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + } + } + + @PreDestroy + public void shutdown() { + if (pdfConversionPool != null && !pdfConversionPool.isShutdown()) { + gracefulShutdown(pdfConversionPool, getShutdownTimeout()); + } + } + + private long getShutdownTimeout() { + try { + String pdfTimeout = System.getProperty("pdf.timeout"); + if (pdfTimeout != null) { + return Long.parseLong(pdfTimeout); + } + return Long.parseLong(ConfigConstants.getCadTimeout()); + } catch (Exception e) { + logger.warn("获取PDF关闭超时时间失败,使用默认值60秒", e); + return 60L; + } + } + + private void gracefulShutdown(ExecutorService executor, long timeoutSeconds) { + logger.info("开始关闭{}...", "PDF转换线程池"); + executor.shutdown(); + + try { + if (!executor.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { + logger.warn("{}超时未关闭,尝试强制关闭...", "PDF转换线程池"); + executor.shutdownNow(); + + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + logger.error("{}无法完全关闭", "PDF转换线程池"); + } else { + logger.info("{}已强制关闭", "PDF转换线程池"); + } + } else { + logger.info("{}已正常关闭", "PDF转换线程池"); + } + } catch (InterruptedException e) { + logger.error("{}关闭时被中断", "PDF转换线程池", e); + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + public List pdf2jpg(String fileNameFilePath, String pdfFilePath, + String pdfName, FileAttribute fileAttribute) throws Exception { + boolean forceUpdatedCache = fileAttribute.forceUpdatedCache(); + boolean usePasswordCache = fileAttribute.getUsePasswordCache(); + String filePassword = fileAttribute.getFilePassword(); + + // 1. 检查缓存 + if (!forceUpdatedCache) { + List cacheResult = fileHandlerService.loadPdf2jpgCache(pdfFilePath); + if (!CollectionUtils.isEmpty(cacheResult)) { + return cacheResult; + } + } + + // 2. 验证文件存在 + File pdfFile = new File(fileNameFilePath); + if (!pdfFile.exists()) { + logger.error("PDF文件不存在: {}", fileNameFilePath); + return null; + } + + // 3. 创建输出目录 + int index = pdfFilePath.lastIndexOf("."); + String folder = pdfFilePath.substring(0, index); + File path = new File(folder); + if (!path.exists() && !path.mkdirs()) { + logger.error("创建转换文件目录失败: {}", folder); + throw new IOException("无法创建输出目录"); + } + + // 4. 加载PDF文档获取页数 + int pageCount = 0; + try (PDDocument tempDoc = Loader.loadPDF(pdfFile, filePassword)) { + pageCount = tempDoc.getNumberOfPages(); + logger.info("PDF文件总页数: {}, 文件: {}", pageCount, pdfFilePath); + } catch (IOException e) { + handlePdfLoadException(e, pdfFilePath); + throw new Exception("PDF文件加载失败", e); + } + + // 5. 根据页数决定转换策略 + List imageUrls; + if (pageCount > MAX_PARALLEL_PAGES) { + // 大文件使用新方案:每页独立加载PDF + imageUrls = convertParallelIndependent(pdfFile, filePassword, pdfFilePath, folder, pageCount); + } else { + // 小文件使用串行处理(稳定) + imageUrls = convertSequentially(pdfFile, filePassword, pdfFilePath, folder, pageCount); + } + + // 6. 缓存结果 + if (usePasswordCache || ObjectUtils.isEmpty(filePassword)) { + fileHandlerService.addPdf2jpgCache(pdfFilePath, pageCount); + } + + logger.info("PDF转换完成,成功转换{}页,文件: {}", imageUrls.size(), pdfFilePath); + return imageUrls; + } + + /** + * 处理PDF加载异常 + */ + private void handlePdfLoadException(Exception e, String pdfFilePath) throws Exception { + Throwable[] throwableArray = ExceptionUtils.getThrowables(e); + for (Throwable throwable : throwableArray) { + if (throwable instanceof IOException || throwable instanceof EncryptedDocumentException) { + if (e.getMessage().toLowerCase().contains(PDF_PASSWORD_MSG)) { + logger.info("PDF文件需要密码: {}", pdfFilePath); + throw new Exception(PDF_PASSWORD_MSG, e); + } + } + } + logger.error("加载PDF文件异常, pdfFilePath:{}", pdfFilePath, e); + throw new Exception("PDF文件加载失败", e); + } + + /** + * 串行转换(稳定方案) + */ + private List convertSequentially(File pdfFile, String filePassword, + String pdfFilePath, String folder, int pageCount) { + List imageUrls = new ArrayList<>(pageCount); + + try (PDDocument doc = Loader.loadPDF(pdfFile, filePassword)) { + doc.setResourceCache(new NotResourceCache()); + PDFRenderer pdfRenderer = new PDFRenderer(doc); + pdfRenderer.setSubsamplingAllowed(true); + + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { + try { + String imageFilePath = folder + File.separator + pageIndex + PDF2JPG_IMAGE_FORMAT; + BufferedImage image = pdfRenderer.renderImageWithDPI( + pageIndex, + ConfigConstants.getPdf2JpgDpi(), + ImageType.RGB + ); + + ImageIOUtil.writeImage(image, imageFilePath, ConfigConstants.getPdf2JpgDpi()); + image.flush(); + + String imageUrl = fileHandlerService.getPdf2jpgUrl(pdfFilePath, pageIndex); + imageUrls.add(imageUrl); + + logger.debug("串行转换页 {} 完成", pageIndex); + + } catch (Exception e) { + logger.error("串行转换页 {} 失败: {}", pageIndex, e.getMessage()); + } + } + } catch (Exception e) { + logger.error("串行转换PDF失败", e); + } + + return imageUrls; + } + + /** + * 并行转换 - 每个线程独立加载PDF(避免线程安全问题) + */ + private List convertParallelIndependent(File pdfFile, String filePassword, + String pdfFilePath, String folder, int pageCount) { + List imageUrls = Collections.synchronizedList(new ArrayList<>()); + List> futures = new ArrayList<>(); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger errorCount = new AtomicInteger(0); + + // 提交页面转换任务 + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { + final int currentPage = pageIndex; + + Future future = pdfConversionPool.submit(() -> { + try { + // 每个任务独立加载PDF,确保线程安全 + try (PDDocument pageDoc = Loader.loadPDF(pdfFile, filePassword)) { + pageDoc.setResourceCache(new NotResourceCache()); + PDFRenderer renderer = new PDFRenderer(pageDoc); + renderer.setSubsamplingAllowed(true); + + String imageFilePath = folder + File.separator + currentPage + PDF2JPG_IMAGE_FORMAT; + BufferedImage image = renderer.renderImageWithDPI( + currentPage, + ConfigConstants.getPdf2JpgDpi(), + ImageType.RGB + ); + + ImageIOUtil.writeImage(image, imageFilePath, ConfigConstants.getPdf2JpgDpi()); + image.flush(); + + String imageUrl = fileHandlerService.getPdf2jpgUrl(pdfFilePath, currentPage); + synchronized (imageUrls) { + imageUrls.add(imageUrl); + } + + successCount.incrementAndGet(); + logger.debug("并行转换页 {} 完成", currentPage); + return true; + } + } catch (Exception e) { + errorCount.incrementAndGet(); + logger.error("并行转换页 {} 失败: {}", currentPage, e.getMessage()); + return false; + } + }); + + futures.add(future); + } + + // 等待所有任务完成 + int timeout = calculateTimeout(pageCount); + long startTime = System.currentTimeMillis(); + + for (Future future : futures) { + try { + future.get(timeout, TimeUnit.SECONDS); + } catch (TimeoutException e) { + logger.warn("页面转换任务超时,取消剩余任务"); + for (Future f : futures) { + if (!f.isDone()) { + f.cancel(true); + } + } + break; + } catch (Exception e) { + logger.error("页面转换任务执行失败", e); + } + + // 检查是否超时 + if (System.currentTimeMillis() - startTime > timeout * 1000L) { + logger.warn("PDF转换整体超时,取消剩余任务"); + for (Future f : futures) { + if (!f.isDone()) { + f.cancel(true); + } + } + break; + } + } + + long elapsedTime = System.currentTimeMillis() - startTime; + logger.info("并行转换统计: 成功={}, 失败={}, 总页数={}, 耗时={}ms", + successCount.get(), errorCount.get(), pageCount, elapsedTime); + + // 按页码排序 + imageUrls.sort(Comparator.comparingInt(url -> { + try { + String pageStr = url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('.')); + return Integer.parseInt(pageStr); + } catch (Exception e) { + return 0; + } + })); + + return imageUrls; + } + + /** + * 计算超时时间 + */ + private int calculateTimeout(int pageCount) { + if (pageCount <= 50) { + return ConfigConstants.getPdfTimeout(); + } else if (pageCount <= 200) { + return ConfigConstants.getPdfTimeout80(); + } else { + return ConfigConstants.getPdfTimeout200(); + } + } + + /** + * 监控线程池状态 + */ + public void monitorThreadPoolStatus() { + if (pdfThreadPoolExecutor != null) { + logger.info("PDF线程池状态: 活跃线程={}, 队列大小={}, 完成任务={}, 线程总数={}", + pdfThreadPoolExecutor.getActiveCount(), + pdfThreadPoolExecutor.getQueue().size(), + pdfThreadPoolExecutor.getCompletedTaskCount(), + pdfThreadPoolExecutor.getPoolSize()); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/cn/keking/service/TifToService.java b/server/src/main/java/cn/keking/service/TifToService.java new file mode 100644 index 00000000..8327a62f --- /dev/null +++ b/server/src/main/java/cn/keking/service/TifToService.java @@ -0,0 +1,426 @@ +package cn.keking.service; + +import cn.keking.config.ConfigConstants; +import cn.keking.utils.WebUtils; +import cn.keking.web.filter.BaseUrlFilter; +import com.itextpdf.text.Document; +import com.itextpdf.text.DocumentException; +import com.itextpdf.text.Image; +import com.itextpdf.text.io.FileChannelRandomAccessSource; +import com.itextpdf.text.pdf.PdfWriter; +import com.itextpdf.text.pdf.RandomAccessFileOrArray; +import com.itextpdf.text.pdf.codec.TiffImage; +import org.apache.commons.imaging.Imaging; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.channels.FileChannel; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +@Component +public class TifToService { + + private static final int FIT_WIDTH = 500; + private static final int FIT_HEIGHT = 900; + private static final Logger logger = LoggerFactory.getLogger(TifToService.class); + private static final String FILE_DIR = ConfigConstants.getFileDir(); + // 用于文档同步的锁对象 + private final Object documentLock = new Object(); + // 专用线程池用于TIF转换 + private static ExecutorService tifConversionPool; + + static { + initThreadPool(); + } + + private static void initThreadPool() { + int corePoolSize = getOptimalThreadCount(); + int maxPoolSize = corePoolSize * 2; + long keepAliveTime = 60L; + + tifConversionPool = new ThreadPoolExecutor( + corePoolSize, + maxPoolSize, + keepAliveTime, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(100), + new ThreadFactory() { + private final AtomicInteger threadNumber = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setName("tif-convert-thread-" + threadNumber.getAndIncrement()); + t.setDaemon(true); + return t; + } + }, + new ThreadPoolExecutor.CallerRunsPolicy() + ); + + logger.info("TIF转换线程池初始化完成,核心线程数: {}, 最大线程数: {}", corePoolSize, maxPoolSize); + } + + private static int getOptimalThreadCount() { + int cpuCores = Runtime.getRuntime().availableProcessors(); + // 对于I/O密集型任务,可以设置更多的线程 + return Math.min(cpuCores * 2, 16); + } + + /** + * 创建 RandomAccessFileOrArray 实例(修复已弃用的构造函数) + */ + private RandomAccessFileOrArray createRandomAccessFileOrArray(File file) throws IOException { + RandomAccessFile aFile = new RandomAccessFile(file, "r"); + FileChannel inChannel = aFile.getChannel(); + FileChannelRandomAccessSource fcra = new FileChannelRandomAccessSource(inChannel); + return new RandomAccessFileOrArray(fcra); + } + + /** + * TIF转JPG - 支持多线程并行处理 + */ + public List convertTif2Jpg(String strInputFile, String strOutputFile, + boolean forceUpdatedCache) throws Exception { + return convertTif2Jpg(strInputFile, strOutputFile, forceUpdatedCache, true); + } + + /** + * TIF转JPG - 可选择是否启用并行处理 + * @param parallelProcessing 是否启用并行处理 + */ + public List convertTif2Jpg(String strInputFile, String strOutputFile, + boolean forceUpdatedCache, boolean parallelProcessing) throws Exception { + String baseUrl = BaseUrlFilter.getBaseUrl(); + String outputDirPath = strOutputFile.substring(0, strOutputFile.lastIndexOf('.')); + + File tiffFile = new File(strInputFile); + if (!tiffFile.exists()) { + logger.error("找不到文件【{}】", strInputFile); + throw new FileNotFoundException("文件不存在: " + strInputFile); + } + + File outputDir = new File(outputDirPath); + if (!outputDir.exists() && !outputDir.mkdirs()) { + throw new IOException("创建目录失败: " + outputDirPath); + } + + // 加载所有图片 + List images; + try { + images = Imaging.getAllBufferedImages(tiffFile); + logger.info("TIF文件加载完成,共{}页,文件: {}", images.size(), strInputFile); + } catch (IOException e) { + handleImagingException(e, strInputFile); + throw e; + } + + int pageCount = images.size(); + + // 根据页面数量决定是否使用并行处理 + boolean useParallel = parallelProcessing && pageCount > 5; + + if (useParallel) { + return convertParallel(images, outputDirPath, baseUrl, forceUpdatedCache); + } else { + return convertSequentially(images, outputDirPath, baseUrl, forceUpdatedCache); + } + } + + /** + * 并行转换 + */ + private List convertParallel(List images, String outputDirPath, + String baseUrl, boolean forceUpdatedCache) { + int pageCount = images.size(); + List> futures = new ArrayList<>(pageCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger skipCount = new AtomicInteger(0); + + long startTime = System.currentTimeMillis(); + + // 提交所有页面转换任务 + for (int i = 0; i < pageCount; i++) { + final int pageIndex = i; + BufferedImage image = images.get(i); + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + String fileName = outputDirPath + File.separator + pageIndex + ".jpg"; + File outputFile = new File(fileName); + + // 检查是否需要转换 + if (forceUpdatedCache || !outputFile.exists()) { + // 使用PNG格式保持更好的质量,如果需要JPG可以调整 + boolean success = ImageIO.write(image, "png", outputFile); + if (!success) { + logger.error("无法写入图片格式,页号: {}", pageIndex); + return null; + } + logger.debug("并行转换图片页 {} 完成", pageIndex); + successCount.incrementAndGet(); + } else { + logger.debug("使用缓存图片页 {}", pageIndex); + skipCount.incrementAndGet(); + } + + // 构建URL + String relativePath = fileName.replace(FILE_DIR, ""); + return baseUrl + WebUtils.encodeFileName(relativePath); + + } catch (Exception e) { + logger.error("并行转换页 {} 失败: {}", pageIndex, e.getMessage()); + return null; + } + }, tifConversionPool); + + futures.add(future); + } + + // 等待所有任务完成并收集结果 + List imageUrls = futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + long elapsedTime = System.currentTimeMillis() - startTime; + + logger.info("TIF并行转换完成: 成功={}, 跳过={}, 总页数={}, 耗时={}ms", + successCount.get(), skipCount.get(), pageCount, elapsedTime); + + return imageUrls; + } + + /** + * 串行转换 + */ + private List convertSequentially(List images, String outputDirPath, + String baseUrl, boolean forceUpdatedCache) throws Exception { + List imageUrls = new ArrayList<>(images.size()); + + for (int i = 0; i < images.size(); i++) { + String fileName = outputDirPath + File.separator + i + ".jpg"; + File outputFile = new File(fileName); + + try { + if (forceUpdatedCache || !outputFile.exists()) { + BufferedImage image = images.get(i); + boolean success = ImageIO.write(image, "png", outputFile); + if (!success) { + throw new IOException("无法写入JPG格式图片: " + fileName); + } + logger.debug("转换图片页 {} 完成", i); + } else { + logger.debug("使用缓存图片页 {}", i); + } + + String relativePath = fileName.replace(FILE_DIR, ""); + String url = baseUrl + WebUtils.encodeFileName(relativePath); + imageUrls.add(url); + + } catch (IOException e) { + logger.error("转换页 {} 失败: {}", i, e.getMessage()); + throw e; + } + } + + return imageUrls; + } + + /** + * 将JPG图片转换为PDF - 优化版本 + */ + public void convertTif2Pdf(String strJpgFile, String strPdfFile) throws Exception { + convertJpg2Pdf(strJpgFile, strPdfFile, true); + } + + /** + * 将JPG图片转换为PDF - 支持并行处理图片加载 + */ + public void convertJpg2Pdf(String strJpgFile, String strPdfFile, boolean parallelLoad) throws Exception { + Document document = new Document(); + FileOutputStream outputStream = null; + RandomAccessFileOrArray rafa = null; + + try { + File tiffFile = new File(strJpgFile); + + // 修复:使用非弃用的方式创建 RandomAccessFileOrArray + rafa = createRandomAccessFileOrArray(tiffFile); + int pages = TiffImage.getNumberOfPages(rafa); + logger.info("开始转换TIFF到PDF,总页数: {}", pages); + + outputStream = new FileOutputStream(strPdfFile); + PdfWriter.getInstance(document, outputStream); + document.open(); + + // 修改为传入File对象而不是RandomAccessFileOrArray + if (parallelLoad && pages > 10) { + convertPagesParallel(document, tiffFile, pages); + } else { + convertPagesSequentially(document, tiffFile, pages); + } + + } catch (IOException e) { + handlePdfConversionException(e, strPdfFile); + throw e; + } finally { + // 修复:传入 rafa 以正确关闭资源 + closeResources(document, rafa, outputStream); + } + + logger.info("PDF转换完成: {}", strPdfFile); + } + + /** + * 串行处理页面 - 修复版 + */ + private void convertPagesSequentially(Document document, File tiffFile, int pages) throws IOException, DocumentException { + RandomAccessFileOrArray rafa = null; + try { + // 修复:使用非弃用的方式创建 RandomAccessFileOrArray + rafa = createRandomAccessFileOrArray(tiffFile); + for (int i = 1; i <= pages; i++) { + Image image = TiffImage.getTiffImage(rafa, i); + image.scaleToFit(FIT_WIDTH, FIT_HEIGHT); + document.add(image); + + if (i % 10 == 0) { + logger.debug("已处理 {} 页", i); + } + } + } finally { + if (rafa != null) { + try { + rafa.close(); + } catch (Exception e) { + logger.warn("关闭RandomAccessFileOrArray失败", e); + } + } + } + } + + /** + * 并行加载并添加图片到PDF - 修复版 + */ + private void convertPagesParallel(Document document, File tiffFile, int pages) { + List> futures = new ArrayList<>(); + + // 提交所有页面加载任务 + for (int i = 1; i <= pages; i++) { + final int pageNum = i; + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + RandomAccessFileOrArray localRafa = null; + try { + // 为每个线程创建独立的RandomAccessFileOrArray + // 修复:使用非弃用的方式创建 RandomAccessFileOrArray + localRafa = createRandomAccessFileOrArray(tiffFile); + + Image image = TiffImage.getTiffImage(localRafa, pageNum); + image.scaleToFit(FIT_WIDTH, FIT_HEIGHT); + logger.debug("并行加载TIFF页 {}", pageNum); + return image; + } catch (Exception e) { + logger.error("加载TIFF页 {} 失败", pageNum, e); + return null; + } finally { + if (localRafa != null) { + try { + localRafa.close(); + } catch (Exception e) { + logger.warn("关闭RandomAccessFileOrArray失败", e); + } + } + } + }, tifConversionPool); + + futures.add(future); + } + // 按顺序添加到文档(保持页面顺序) + for (int i = 0; i < futures.size(); i++) { + try { + Image image = futures.get(i).get(30, TimeUnit.SECONDS); + if (image != null) { + // 使用专门的锁对象而不是同步document参数 + synchronized (documentLock) { + document.add(image); + } + } + } catch (Exception e) { + logger.error("添加页 {} 到PDF失败", i + 1, e); + } + } + } + + /** + * 异常处理 + */ + private void handleImagingException(IOException e, String filePath) { + if (!e.getMessage().contains("Only sequential, baseline JPEGs are supported at the moment")) { + logger.error("TIF转JPG异常,文件路径:{}", filePath, e); + } else { + logger.warn("不支持的非基线JPEG格式,文件:{}", filePath); + } + } + + private void handlePdfConversionException(IOException e, String filePath) { + if (!e.getMessage().contains("Bad endianness tag (not 0x4949 or 0x4d4d)")) { + logger.error("TIF转PDF异常,文件路径:{}", filePath, e); + } else { + logger.warn("TIFF文件字节顺序标记错误,文件:{}", filePath); + } + } + + /** + * 资源关闭 + */ + private void closeResources(Document document, RandomAccessFileOrArray rafa, FileOutputStream outputStream) { + try { + if (document != null && document.isOpen()) { + document.close(); + } + } catch (Exception e) { + logger.warn("关闭Document失败", e); + } + + try { + if (rafa != null) { + rafa.close(); + } + } catch (Exception e) { + logger.warn("关闭RandomAccessFileOrArray失败", e); + } + + try { + if (outputStream != null) { + outputStream.close(); + } + } catch (Exception e) { + logger.warn("关闭FileOutputStream失败", e); + } + } + + /** + * 优雅关闭 + */ + public static void shutdown() { + if (tifConversionPool != null && !tifConversionPool.isShutdown()) { + tifConversionPool.shutdown(); + try { + if (!tifConversionPool.awaitTermination(30, TimeUnit.SECONDS)) { + tifConversionPool.shutdownNow(); + } + } catch (InterruptedException e) { + tifConversionPool.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } +} \ No newline at end of file