mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
feat(conversion): add PDF to Excel (XLSX) conversion (#5778)
This commit is contained in:
@@ -66,6 +66,7 @@ dependencies {
|
||||
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
implementation ('technology.tabula:tabula:1.0.5') {
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.util.WorkbookUtil;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.PDFWithPageNums;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
|
||||
import technology.tabula.ObjectExtractor;
|
||||
import technology.tabula.Page;
|
||||
import technology.tabula.RectangularTextContainer;
|
||||
import technology.tabula.Table;
|
||||
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
|
||||
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToExcelController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@AutoJobPostMapping(value = "/pdf/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert a PDF to an Excel spreadsheet (XLSX)",
|
||||
description =
|
||||
"Extracts tabular data from each page of a PDF and writes it into an Excel"
|
||||
+ " workbook, one sheet per table. Input:PDF Output:XLSX Type:SISO")
|
||||
public ResponseEntity<byte[]> pdfToExcel(@ModelAttribute PDFWithPageNums request)
|
||||
throws Exception {
|
||||
String baseName =
|
||||
GeneralUtils.removeExtension(request.getFileInput().getOriginalFilename());
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request);
|
||||
XSSFWorkbook workbook = new XSSFWorkbook();
|
||||
ObjectExtractor extractor = new ObjectExtractor(document)) {
|
||||
|
||||
List<Integer> pages = request.getPageNumbersList(document, true);
|
||||
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
|
||||
int sheetCount = 0;
|
||||
|
||||
for (int pageNum : pages) {
|
||||
Page page = extractor.extract(pageNum);
|
||||
List<Table> tables = sea.extract(page);
|
||||
|
||||
for (int tableIdx = 0; tableIdx < tables.size(); tableIdx++) {
|
||||
Table table = tables.get(tableIdx);
|
||||
String sheetName =
|
||||
tables.size() == 1
|
||||
? String.format(Locale.ROOT, "Page %d", pageNum)
|
||||
: String.format(
|
||||
Locale.ROOT,
|
||||
"Page %d Table %d",
|
||||
pageNum,
|
||||
tableIdx + 1);
|
||||
|
||||
sheetName = getUniqueSheetName(workbook, sheetName);
|
||||
Sheet sheet = workbook.createSheet(sheetName);
|
||||
List<List<RectangularTextContainer>> rows = table.getRows();
|
||||
|
||||
for (int rowIdx = 0; rowIdx < rows.size(); rowIdx++) {
|
||||
Row excelRow = sheet.createRow(rowIdx);
|
||||
List<RectangularTextContainer> cells = rows.get(rowIdx);
|
||||
for (int cellIdx = 0; cellIdx < cells.size(); cellIdx++) {
|
||||
Cell excelCell = excelRow.createCell(cellIdx);
|
||||
excelCell.setCellValue(cells.get(cellIdx).getText());
|
||||
}
|
||||
}
|
||||
sheetCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetCount == 0) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
workbook.write(baos);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentDisposition(
|
||||
ContentDisposition.builder("attachment").filename(baseName + ".xlsx").build());
|
||||
headers.setContentType(
|
||||
MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(baos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private String getUniqueSheetName(Workbook workbook, String baseName) {
|
||||
String safeName = WorkbookUtil.createSafeSheetName(baseName);
|
||||
String uniqueName = safeName;
|
||||
int count = 1;
|
||||
while (workbook.getSheet(uniqueName) != null) {
|
||||
String suffix = " (" + count + ")";
|
||||
if (safeName.length() + suffix.length() > 31) {
|
||||
uniqueName = safeName.substring(0, 31 - suffix.length()) + suffix;
|
||||
} else {
|
||||
uniqueName = safeName + suffix;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return uniqueName;
|
||||
}
|
||||
}
|
||||
@@ -4649,6 +4649,13 @@ prompt = "Choose page to extract table"
|
||||
submit = "Extract"
|
||||
title = "PDF to CSV"
|
||||
|
||||
[PDFToXLSX]
|
||||
header = "PDF to Excel"
|
||||
prompt = "Choose pages to extract tables"
|
||||
submit = "Convert"
|
||||
title = "PDF to Excel (XLSX)"
|
||||
tags = "spreadsheet,excel,xlsx,table,extract,convert"
|
||||
|
||||
[PDFToHTML]
|
||||
credit = "This service uses pdftohtml for file conversion."
|
||||
header = "PDF to HTML"
|
||||
|
||||
@@ -28,6 +28,7 @@ export const CONVERSION_ENDPOINTS = {
|
||||
'pdf-office-presentation': '/api/v1/convert/pdf/presentation',
|
||||
'pdf-office-text': '/api/v1/convert/pdf/text',
|
||||
'pdf-csv': '/api/v1/convert/pdf/csv',
|
||||
'pdf-xlsx': '/api/v1/convert/pdf/xlsx',
|
||||
'pdf-markdown': '/api/v1/convert/pdf/markdown',
|
||||
'pdf-html': '/api/v1/convert/pdf/html',
|
||||
'pdf-xml': '/api/v1/convert/pdf/xml',
|
||||
@@ -54,6 +55,7 @@ export const ENDPOINT_NAMES = {
|
||||
'pdf-office-presentation': 'pdf-to-presentation',
|
||||
'pdf-office-text': 'pdf-to-text',
|
||||
'pdf-csv': 'pdf-to-csv',
|
||||
'pdf-xlsx': 'pdf-to-xlsx',
|
||||
'pdf-markdown': 'pdf-to-markdown',
|
||||
'pdf-html': 'pdf-to-html',
|
||||
'pdf-xml': 'pdf-to-xml',
|
||||
@@ -116,6 +118,7 @@ export const TO_FORMAT_OPTIONS = [
|
||||
{ value: 'cbz', label: 'CBZ', group: 'Archive' },
|
||||
{ value: 'cbr', label: 'CBR', group: 'Archive' },
|
||||
{ value: 'csv', label: 'CSV', group: 'Spreadsheet' },
|
||||
{ value: 'xlsx', label: 'XLSX', group: 'Spreadsheet' },
|
||||
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
|
||||
{ value: 'odp', label: 'ODP', group: 'Presentation' },
|
||||
{ value: 'txt', label: 'TXT', group: 'Text' },
|
||||
@@ -137,7 +140,7 @@ export const TO_FORMAT_OPTIONS = [
|
||||
export const CONVERSION_MATRIX: Record<string, string[]> = {
|
||||
'any': ['pdf'], // Mixed files always convert to PDF
|
||||
'image': ['pdf'], // Multiple images always convert to PDF
|
||||
'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'pdfx', 'cbz', 'cbr', 'epub', 'azw3'],
|
||||
'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'xlsx', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'pdfx', 'cbz', 'cbr', 'epub', 'azw3'],
|
||||
'cbz': ['pdf'],
|
||||
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
|
||||
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'],
|
||||
@@ -162,6 +165,7 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
|
||||
'docx': 'pdf-to-word', 'odt': 'pdf-to-word',
|
||||
'pptx': 'pdf-to-presentation', 'odp': 'pdf-to-presentation',
|
||||
'csv': 'pdf-to-csv',
|
||||
'xlsx': 'pdf-to-xlsx',
|
||||
'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown',
|
||||
'html': 'pdf-to-html', 'xml': 'pdf-to-xml',
|
||||
'pdfa': 'pdf-to-pdfa',
|
||||
|
||||
@@ -916,6 +916,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
"markdown-to-pdf",
|
||||
"file-to-pdf",
|
||||
"pdf-to-csv",
|
||||
"pdf-to-xlsx",
|
||||
"pdf-to-markdown",
|
||||
"pdf-to-pdfa",
|
||||
"eml-to-pdf",
|
||||
|
||||
@@ -21,8 +21,8 @@ export const shouldProcessFilesSeparately = (
|
||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||
// PDF to PDF/A and PDF/X conversions (each PDF should be processed separately)
|
||||
(parameters.fromExtension === 'pdf' && (parameters.toExtension === 'pdfa' || parameters.toExtension === 'pdfx')) ||
|
||||
// PDF to text-like formats should be one output per input
|
||||
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) ||
|
||||
// PDF to text-like/spreadsheet formats should be one output per input
|
||||
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv', 'xlsx'].includes(parameters.toExtension)) ||
|
||||
// PDF to CBR conversions (each PDF should generate its own archive)
|
||||
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbr') ||
|
||||
// PDF to EPUB/AZW3 conversions (each PDF should generate its own ebook)
|
||||
@@ -85,6 +85,8 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
|
||||
formData.append("outputFormat", pdfxOptions?.outputFormat || 'pdfx');
|
||||
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
|
||||
formData.append("pageNumbers", "all");
|
||||
} else if (fromExtension === 'pdf' && toExtension === 'xlsx') {
|
||||
formData.append("pageNumbers", "all");
|
||||
} else if (fromExtension === 'cbr' && toExtension === 'pdf') {
|
||||
formData.append("optimizeForEbook", cbrOptions.optimizeForEbook.toString());
|
||||
} else if (fromExtension === 'pdf' && toExtension === 'cbr') {
|
||||
|
||||
@@ -107,6 +107,13 @@ const ALL_CONVERSION_ENDPOINTS: ConversionEndpoint[] = [
|
||||
description: 'Extract CSV data from PDF',
|
||||
apiPath: '/api/v1/convert/pdf/csv'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-xlsx',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'xlsx',
|
||||
description: 'Extract Excel spreadsheet from PDF',
|
||||
apiPath: '/api/v1/convert/pdf/xlsx'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-markdown',
|
||||
fromFormat: 'pdf',
|
||||
|
||||
@@ -23,6 +23,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/html-to-pdf': 'convert',
|
||||
'/markdown-to-pdf': 'convert',
|
||||
'/pdf-to-csv': 'convert',
|
||||
'/pdf-to-xlsx': 'convert',
|
||||
'/pdf-to-img': 'convert',
|
||||
'/pdf-to-markdown': 'convert',
|
||||
'/pdf-to-pdfa': 'convert',
|
||||
|
||||
+1
@@ -143,6 +143,7 @@ export default function AdminEndpointsSection() {
|
||||
'ocr-pdf',
|
||||
'overlay-pdf',
|
||||
'pdf-to-csv',
|
||||
'pdf-to-xlsx',
|
||||
'pdf-to-epub',
|
||||
'pdf-to-html',
|
||||
'pdf-to-img',
|
||||
|
||||
Reference in New Issue
Block a user