mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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 'javax.xml.bind:jaxb-api:2.3.1'
|
||||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||||
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
|
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
|
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||||
implementation ('technology.tabula:tabula:1.0.5') {
|
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"
|
submit = "Extract"
|
||||||
title = "PDF to CSV"
|
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]
|
[PDFToHTML]
|
||||||
credit = "This service uses pdftohtml for file conversion."
|
credit = "This service uses pdftohtml for file conversion."
|
||||||
header = "PDF to HTML"
|
header = "PDF to HTML"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const CONVERSION_ENDPOINTS = {
|
|||||||
'pdf-office-presentation': '/api/v1/convert/pdf/presentation',
|
'pdf-office-presentation': '/api/v1/convert/pdf/presentation',
|
||||||
'pdf-office-text': '/api/v1/convert/pdf/text',
|
'pdf-office-text': '/api/v1/convert/pdf/text',
|
||||||
'pdf-csv': '/api/v1/convert/pdf/csv',
|
'pdf-csv': '/api/v1/convert/pdf/csv',
|
||||||
|
'pdf-xlsx': '/api/v1/convert/pdf/xlsx',
|
||||||
'pdf-markdown': '/api/v1/convert/pdf/markdown',
|
'pdf-markdown': '/api/v1/convert/pdf/markdown',
|
||||||
'pdf-html': '/api/v1/convert/pdf/html',
|
'pdf-html': '/api/v1/convert/pdf/html',
|
||||||
'pdf-xml': '/api/v1/convert/pdf/xml',
|
'pdf-xml': '/api/v1/convert/pdf/xml',
|
||||||
@@ -54,6 +55,7 @@ export const ENDPOINT_NAMES = {
|
|||||||
'pdf-office-presentation': 'pdf-to-presentation',
|
'pdf-office-presentation': 'pdf-to-presentation',
|
||||||
'pdf-office-text': 'pdf-to-text',
|
'pdf-office-text': 'pdf-to-text',
|
||||||
'pdf-csv': 'pdf-to-csv',
|
'pdf-csv': 'pdf-to-csv',
|
||||||
|
'pdf-xlsx': 'pdf-to-xlsx',
|
||||||
'pdf-markdown': 'pdf-to-markdown',
|
'pdf-markdown': 'pdf-to-markdown',
|
||||||
'pdf-html': 'pdf-to-html',
|
'pdf-html': 'pdf-to-html',
|
||||||
'pdf-xml': 'pdf-to-xml',
|
'pdf-xml': 'pdf-to-xml',
|
||||||
@@ -116,6 +118,7 @@ export const TO_FORMAT_OPTIONS = [
|
|||||||
{ value: 'cbz', label: 'CBZ', group: 'Archive' },
|
{ value: 'cbz', label: 'CBZ', group: 'Archive' },
|
||||||
{ value: 'cbr', label: 'CBR', group: 'Archive' },
|
{ value: 'cbr', label: 'CBR', group: 'Archive' },
|
||||||
{ value: 'csv', label: 'CSV', group: 'Spreadsheet' },
|
{ value: 'csv', label: 'CSV', group: 'Spreadsheet' },
|
||||||
|
{ value: 'xlsx', label: 'XLSX', group: 'Spreadsheet' },
|
||||||
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
|
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
|
||||||
{ value: 'odp', label: 'ODP', group: 'Presentation' },
|
{ value: 'odp', label: 'ODP', group: 'Presentation' },
|
||||||
{ value: 'txt', label: 'TXT', group: 'Text' },
|
{ value: 'txt', label: 'TXT', group: 'Text' },
|
||||||
@@ -137,7 +140,7 @@ export const TO_FORMAT_OPTIONS = [
|
|||||||
export const CONVERSION_MATRIX: Record<string, string[]> = {
|
export const CONVERSION_MATRIX: Record<string, string[]> = {
|
||||||
'any': ['pdf'], // Mixed files always convert to PDF
|
'any': ['pdf'], // Mixed files always convert to PDF
|
||||||
'image': ['pdf'], // Multiple images 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'],
|
'cbz': ['pdf'],
|
||||||
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
|
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
|
||||||
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['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',
|
'docx': 'pdf-to-word', 'odt': 'pdf-to-word',
|
||||||
'pptx': 'pdf-to-presentation', 'odp': 'pdf-to-presentation',
|
'pptx': 'pdf-to-presentation', 'odp': 'pdf-to-presentation',
|
||||||
'csv': 'pdf-to-csv',
|
'csv': 'pdf-to-csv',
|
||||||
|
'xlsx': 'pdf-to-xlsx',
|
||||||
'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown',
|
'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown',
|
||||||
'html': 'pdf-to-html', 'xml': 'pdf-to-xml',
|
'html': 'pdf-to-html', 'xml': 'pdf-to-xml',
|
||||||
'pdfa': 'pdf-to-pdfa',
|
'pdfa': 'pdf-to-pdfa',
|
||||||
|
|||||||
@@ -916,6 +916,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
"markdown-to-pdf",
|
"markdown-to-pdf",
|
||||||
"file-to-pdf",
|
"file-to-pdf",
|
||||||
"pdf-to-csv",
|
"pdf-to-csv",
|
||||||
|
"pdf-to-xlsx",
|
||||||
"pdf-to-markdown",
|
"pdf-to-markdown",
|
||||||
"pdf-to-pdfa",
|
"pdf-to-pdfa",
|
||||||
"eml-to-pdf",
|
"eml-to-pdf",
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export const shouldProcessFilesSeparately = (
|
|||||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||||
// PDF to PDF/A and PDF/X conversions (each PDF should be processed separately)
|
// PDF to PDF/A and PDF/X conversions (each PDF should be processed separately)
|
||||||
(parameters.fromExtension === 'pdf' && (parameters.toExtension === 'pdfa' || parameters.toExtension === 'pdfx')) ||
|
(parameters.fromExtension === 'pdf' && (parameters.toExtension === 'pdfa' || parameters.toExtension === 'pdfx')) ||
|
||||||
// PDF to text-like formats should be one output per input
|
// PDF to text-like/spreadsheet formats should be one output per input
|
||||||
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) ||
|
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv', 'xlsx'].includes(parameters.toExtension)) ||
|
||||||
// PDF to CBR conversions (each PDF should generate its own archive)
|
// PDF to CBR conversions (each PDF should generate its own archive)
|
||||||
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbr') ||
|
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbr') ||
|
||||||
// PDF to EPUB/AZW3 conversions (each PDF should generate its own ebook)
|
// 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');
|
formData.append("outputFormat", pdfxOptions?.outputFormat || 'pdfx');
|
||||||
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
|
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
|
||||||
formData.append("pageNumbers", "all");
|
formData.append("pageNumbers", "all");
|
||||||
|
} else if (fromExtension === 'pdf' && toExtension === 'xlsx') {
|
||||||
|
formData.append("pageNumbers", "all");
|
||||||
} else if (fromExtension === 'cbr' && toExtension === 'pdf') {
|
} else if (fromExtension === 'cbr' && toExtension === 'pdf') {
|
||||||
formData.append("optimizeForEbook", cbrOptions.optimizeForEbook.toString());
|
formData.append("optimizeForEbook", cbrOptions.optimizeForEbook.toString());
|
||||||
} else if (fromExtension === 'pdf' && toExtension === 'cbr') {
|
} else if (fromExtension === 'pdf' && toExtension === 'cbr') {
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ const ALL_CONVERSION_ENDPOINTS: ConversionEndpoint[] = [
|
|||||||
description: 'Extract CSV data from PDF',
|
description: 'Extract CSV data from PDF',
|
||||||
apiPath: '/api/v1/convert/pdf/csv'
|
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',
|
endpoint: 'pdf-to-markdown',
|
||||||
fromFormat: 'pdf',
|
fromFormat: 'pdf',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
|||||||
'/html-to-pdf': 'convert',
|
'/html-to-pdf': 'convert',
|
||||||
'/markdown-to-pdf': 'convert',
|
'/markdown-to-pdf': 'convert',
|
||||||
'/pdf-to-csv': 'convert',
|
'/pdf-to-csv': 'convert',
|
||||||
|
'/pdf-to-xlsx': 'convert',
|
||||||
'/pdf-to-img': 'convert',
|
'/pdf-to-img': 'convert',
|
||||||
'/pdf-to-markdown': 'convert',
|
'/pdf-to-markdown': 'convert',
|
||||||
'/pdf-to-pdfa': 'convert',
|
'/pdf-to-pdfa': 'convert',
|
||||||
|
|||||||
+1
@@ -143,6 +143,7 @@ export default function AdminEndpointsSection() {
|
|||||||
'ocr-pdf',
|
'ocr-pdf',
|
||||||
'overlay-pdf',
|
'overlay-pdf',
|
||||||
'pdf-to-csv',
|
'pdf-to-csv',
|
||||||
|
'pdf-to-xlsx',
|
||||||
'pdf-to-epub',
|
'pdf-to-epub',
|
||||||
'pdf-to-html',
|
'pdf-to-html',
|
||||||
'pdf-to-img',
|
'pdf-to-img',
|
||||||
|
|||||||
Reference in New Issue
Block a user