mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
feat(conversion): add SVG to PDF conversion functionality (#5431)
# Description of Changes This pull request introduces a new SVG to PDF conversion feature, including both backend and frontend changes. The backend adds a secure, vector-preserving SVG-to-PDF conversion endpoint with comprehensive SVG sanitization to prevent XSS and SSRF attacks. The frontend is updated to route SVG-to-PDF conversions through this new endpoint and to distinguish SVG from other image formats. Additionally, a new dependency is added for PDF rendering. **Backend: SVG to PDF Conversion and Security** * Adds a new API endpoint and controller (`ConvertSvgToPDF`) for converting SVG files to PDF, using Batik and PDFBox with vector graphics preservation and robust error handling. * Implements SVG sanitization (`SvgSanitizer`) to remove scripts, event handlers, and dangerous URLs, protecting against XSS and SSRF attacks. * Introduces a utility (`SvgToPdf`) for rendering SVG to PDF with timeout protection against resource exhaustion attacks. * Defines a new request model (`SvgToPdfRequest`) for SVG to PDF conversion requests. * Adds the `pdfbox-graphics2d` dependency for vector graphics PDF rendering. **Frontend: Routing and Format Handling** * Updates conversion endpoint constants to add `svg-pdf` and maps SVG files to use the new `svg-to-pdf` route instead of the generic image-to-PDF route. * Removes SVG from the generic image format list and introduces a dedicated check for SVG format (`isSvgFormat`). <img width="1133" height="995" alt="image" src="https://github.com/user-attachments/assets/dec8cf27-ccb9-490d-af76-bff69feb0423" /> <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [X] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs <[email protected]>
This commit is contained in:
+244
@@ -0,0 +1,244 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.MultiFileResponse;
|
||||
import stirling.software.SPDF.model.api.converters.SvgToPdfRequest;
|
||||
import stirling.software.SPDF.utils.SvgToPdf;
|
||||
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 stirling.software.common.util.SvgSanitizer;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertSvgToPDF {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final SvgSanitizer svgSanitizer;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/svg/pdf")
|
||||
@MultiFileResponse
|
||||
@Operation(
|
||||
summary = "Convert SVG to PDF",
|
||||
description =
|
||||
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF format. "
|
||||
+ "Each SVG is converted to a separate PDF file. "
|
||||
+ "The conversion preserves vector graphics for crisp output at any resolution - no rasterization occurs. "
|
||||
+ "SVG dimensions (width/height) determine the PDF page size; defaults to A4 if not specified. "
|
||||
+ "SVG content is sanitized to prevent XSS attacks. "
|
||||
+ "Input: SVG file(s), Output: PDF file(s) or ZIP. Type: MIMO")
|
||||
public ResponseEntity<byte[]> convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) {
|
||||
|
||||
MultipartFile[] inputFiles = request.getFileInput();
|
||||
boolean combineIntoSinglePdf = Boolean.TRUE.equals(request.getCombineIntoSinglePdf());
|
||||
|
||||
// Validate input
|
||||
if (inputFiles == null || inputFiles.length == 0) {
|
||||
log.error("No files provided for SVG to PDF conversion.");
|
||||
return ResponseEntity.badRequest()
|
||||
.body("No files provided".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
try {
|
||||
List<byte[]> sanitizedSvgs = new ArrayList<>();
|
||||
List<String> filenames = new ArrayList<>();
|
||||
|
||||
for (MultipartFile inputFile : inputFiles) {
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
log.warn("Skipping empty file in batch conversion");
|
||||
continue;
|
||||
}
|
||||
|
||||
String originalFilename = inputFile.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.trim().isEmpty()) {
|
||||
log.warn("Skipping file with null or empty filename");
|
||||
continue;
|
||||
}
|
||||
|
||||
String lowerFilename = originalFilename.toLowerCase(Locale.ROOT);
|
||||
if (!lowerFilename.endsWith(".svg")) {
|
||||
log.warn("Skipping non-SVG file: {}", originalFilename);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] fileBytes = inputFile.getBytes();
|
||||
byte[] sanitizedBytes = svgSanitizer.sanitize(fileBytes);
|
||||
sanitizedSvgs.add(sanitizedBytes);
|
||||
filenames.add(Filenames.toSimpleFileName(originalFilename));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"SVG sanitization/reading failed for {}: {}",
|
||||
originalFilename,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedSvgs.isEmpty()) {
|
||||
log.error("No valid SVG files were found");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body("No valid SVG files were found".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
if (combineIntoSinglePdf) {
|
||||
return handleCombinedConversion(sanitizedSvgs, filenames);
|
||||
} else {
|
||||
return handleSeparateConversion(sanitizedSvgs, filenames);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error during SVG to PDF conversion", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
"An unexpected error occurred during conversion"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> handleCombinedConversion(
|
||||
List<byte[]> sanitizedSvgs, List<String> filenames) {
|
||||
try {
|
||||
log.info("Combining {} SVG files into single PDF", sanitizedSvgs.size());
|
||||
|
||||
byte[] pdfBytes = SvgToPdf.combineIntoPdf(sanitizedSvgs);
|
||||
|
||||
if (pdfBytes == null || pdfBytes.length == 0) {
|
||||
log.error("PDF conversion failed - empty output");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
"PDF conversion failed - empty output"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
|
||||
String outputFilename =
|
||||
filenames.isEmpty()
|
||||
? "combined_svgs.pdf"
|
||||
: GeneralUtils.generateFilename(filenames.get(0), "_combined.pdf");
|
||||
|
||||
log.info("Successfully combined {} SVGs into single PDF", sanitizedSvgs.size());
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfBytes, outputFilename, MediaType.APPLICATION_PDF);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Error combining SVGs into PDF", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
("Conversion failed: " + e.getMessage())
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> handleSeparateConversion(
|
||||
List<byte[]> sanitizedSvgs, List<String> filenames) {
|
||||
List<ConvertedPdf> convertedPdfs = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < sanitizedSvgs.size(); i++) {
|
||||
byte[] sanitizedBytes = sanitizedSvgs.get(i);
|
||||
String baseFilename = filenames.get(i);
|
||||
|
||||
try {
|
||||
byte[] pdfBytes = SvgToPdf.convert(sanitizedBytes);
|
||||
|
||||
if (pdfBytes == null || pdfBytes.length == 0) {
|
||||
log.error("PDF conversion failed - empty output for {}", baseFilename);
|
||||
continue;
|
||||
}
|
||||
|
||||
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
|
||||
String outputFilename = GeneralUtils.generateFilename(baseFilename, ".pdf");
|
||||
convertedPdfs.add(new ConvertedPdf(outputFilename, pdfBytes));
|
||||
|
||||
log.info("Successfully converted SVG to PDF: {}", baseFilename);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("File processing error for SVG to PDF: {}", baseFilename, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (convertedPdfs.isEmpty()) {
|
||||
log.error("No files were successfully converted");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("No files were successfully converted".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
try {
|
||||
if (convertedPdfs.size() == 1) {
|
||||
ConvertedPdf pdf = convertedPdfs.get(0);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdf.content, pdf.filename, MediaType.APPLICATION_PDF);
|
||||
}
|
||||
|
||||
String zipFilename =
|
||||
filenames.isEmpty()
|
||||
? "converted_svgs.zip"
|
||||
: GeneralUtils.generateFilename(
|
||||
filenames.get(0), "_converted_svgs.zip");
|
||||
byte[] zipBytes = createZipFromPdfs(convertedPdfs);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to create response", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to create response".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] createZipFromPdfs(List<ConvertedPdf> pdfs) throws IOException {
|
||||
try (TempFile tempZipFile = new TempFile(tempFileManager, ".zip");
|
||||
ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(tempZipFile.getPath()))) {
|
||||
|
||||
for (ConvertedPdf pdf : pdfs) {
|
||||
ZipEntry pdfEntry = new ZipEntry(pdf.filename);
|
||||
zipOut.putNextEntry(pdfEntry);
|
||||
zipOut.write(pdf.content);
|
||||
zipOut.closeEntry();
|
||||
log.debug("Added {} to ZIP", pdf.filename);
|
||||
}
|
||||
|
||||
return Files.readAllBytes(tempZipFile.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
private static class ConvertedPdf {
|
||||
final String filename;
|
||||
final byte[] content;
|
||||
|
||||
ConvertedPdf(String filename, byte[] content) {
|
||||
this.filename = filename;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-7
@@ -1,7 +1,12 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -14,11 +19,11 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.OverlayImageRequest;
|
||||
import stirling.software.SPDF.utils.SvgOverlayUtil;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@MiscApi
|
||||
@@ -32,25 +37,63 @@ public class OverlayImageController {
|
||||
@Operation(
|
||||
summary = "Overlay image onto a PDF file",
|
||||
description =
|
||||
"This endpoint overlays an image onto a PDF file at the specified coordinates."
|
||||
+ " The image can be overlaid on every page of the PDF if specified. "
|
||||
+ " Input:PDF/IMAGE Output:PDF Type:SISO")
|
||||
"This endpoint overlays an image onto a PDF file at the specified coordinates. "
|
||||
+ "Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG). "
|
||||
+ "SVG files are rendered as vector graphics for crisp output at any resolution. "
|
||||
+ "The image can be overlaid on every page of the PDF if specified. "
|
||||
+ "Input:PDF/IMAGE/SVG Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> overlayImage(@ModelAttribute OverlayImageRequest request) {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
MultipartFile imageFile = request.getImageFile();
|
||||
float x = request.getX();
|
||||
float y = request.getY();
|
||||
boolean everyPage = Boolean.TRUE.equals(request.getEveryPage());
|
||||
|
||||
try {
|
||||
byte[] pdfBytes = pdfFile.getBytes();
|
||||
byte[] imageBytes = imageFile.getBytes();
|
||||
byte[] result =
|
||||
PdfUtils.overlayImage(
|
||||
pdfDocumentFactory, pdfBytes, imageBytes, x, y, everyPage);
|
||||
|
||||
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfBytes);
|
||||
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
|
||||
if (isSvg) {
|
||||
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
|
||||
} else {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF page {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
log.info("PDF with overlaid image successfully created");
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_overlayed.pdf"));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to add image to PDF", e);
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class SvgToPdfRequest {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The SVG file(s) to be converted to PDF. "
|
||||
+ "SVGs are scalable and have inherent dimensions - the conversion uses these dimensions "
|
||||
+ "to determine the PDF page size. If dimensions are not specified in the SVG, A4 size is used.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private MultipartFile[] fileInput;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Whether to combine all SVG files into a single PDF (each SVG as a separate page) "
|
||||
+ "or create separate PDF files for each SVG.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
defaultValue = "false")
|
||||
private Boolean combineIntoSinglePdf;
|
||||
}
|
||||
+4
-1
@@ -14,7 +14,10 @@ import stirling.software.common.model.api.PDFFile;
|
||||
public class OverlayImageRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The image file to be overlaid onto the PDF.",
|
||||
description =
|
||||
"The image file to be overlaid onto the PDF. "
|
||||
+ "Supports raster formats (PNG, JPEG, etc.) and vector format (SVG). "
|
||||
+ "SVG files are rendered as vector graphics for crisp output at any resolution.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
format = "binary")
|
||||
private MultipartFile imageFile;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
|
||||
import org.apache.batik.bridge.BridgeContext;
|
||||
import org.apache.batik.bridge.DocumentLoader;
|
||||
import org.apache.batik.bridge.GVTBuilder;
|
||||
import org.apache.batik.bridge.UserAgent;
|
||||
import org.apache.batik.bridge.UserAgentAdapter;
|
||||
import org.apache.batik.gvt.GraphicsNode;
|
||||
import org.apache.batik.util.XMLResourceDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.w3c.dom.svg.SVGDocument;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import de.rototor.pdfbox.graphics2d.PdfBoxGraphics2D;
|
||||
|
||||
@UtilityClass
|
||||
@Slf4j
|
||||
public class SvgOverlayUtil {
|
||||
|
||||
public void overlaySvgOnPage(
|
||||
PDDocument document, PDPage page, byte[] svgBytes, float x, float y)
|
||||
throws IOException {
|
||||
try {
|
||||
String parser = XMLResourceDescriptor.getXMLParserClassName();
|
||||
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser);
|
||||
|
||||
SVGDocument svgDoc;
|
||||
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svgBytes)) {
|
||||
svgDoc = factory.createSVGDocument("file:///overlay.svg", inputStream);
|
||||
}
|
||||
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
GVTBuilder builder = new GVTBuilder();
|
||||
GraphicsNode rootNode = builder.build(ctx, svgDoc);
|
||||
|
||||
float svgWidth = (float) ctx.getDocumentSize().getWidth();
|
||||
float svgHeight = (float) ctx.getDocumentSize().getHeight();
|
||||
|
||||
PdfBoxGraphics2D pdfGraphics = new PdfBoxGraphics2D(document, svgWidth, svgHeight);
|
||||
|
||||
try {
|
||||
rootNode.paint(pdfGraphics);
|
||||
} finally {
|
||||
pdfGraphics.dispose();
|
||||
}
|
||||
|
||||
PDFormXObject xform = pdfGraphics.getXFormObject();
|
||||
|
||||
try (PDPageContentStream newContentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
newContentStream.saveGraphicsState();
|
||||
|
||||
newContentStream.transform(new Matrix(1, 0, 0, 1, x, y));
|
||||
|
||||
newContentStream.drawForm(xform);
|
||||
|
||||
newContentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
log.info("SVG successfully overlaid as vector graphic at ({}, {})", x, y);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to overlay SVG as vector graphic", e);
|
||||
throw new IOException("SVG overlay failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isSvgImage(byte[] bytes) {
|
||||
if (bytes == null || bytes.length < 5) {
|
||||
return false;
|
||||
}
|
||||
// Check for SVG markers: <?xml or <svg
|
||||
String start =
|
||||
new String(
|
||||
bytes,
|
||||
0,
|
||||
Math.min(200, bytes.length),
|
||||
java.nio.charset.StandardCharsets.UTF_8)
|
||||
.toLowerCase();
|
||||
return start.contains("<svg") || (start.contains("<?xml") && start.contains("svg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
|
||||
import org.apache.batik.bridge.BridgeContext;
|
||||
import org.apache.batik.bridge.DocumentLoader;
|
||||
import org.apache.batik.bridge.GVTBuilder;
|
||||
import org.apache.batik.bridge.UserAgent;
|
||||
import org.apache.batik.bridge.UserAgentAdapter;
|
||||
import org.apache.batik.gvt.GraphicsNode;
|
||||
import org.apache.batik.util.XMLResourceDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.w3c.dom.svg.SVGDocument;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import de.rototor.pdfbox.graphics2d.PdfBoxGraphics2D;
|
||||
|
||||
@UtilityClass
|
||||
@Slf4j
|
||||
public class SvgToPdf {
|
||||
|
||||
/** Default page width in points (A4) */
|
||||
private static final float DEFAULT_PAGE_WIDTH = 595f;
|
||||
|
||||
/** Default page height in points (A4) */
|
||||
private static final float DEFAULT_PAGE_HEIGHT = 842f;
|
||||
|
||||
/** Timeout for SVG rendering in seconds (prevents DoS via complex SVGs) */
|
||||
private static final int RENDERING_TIMEOUT_SECONDS = 30;
|
||||
|
||||
public byte[] convert(byte[] svgBytes) throws IOException {
|
||||
if (svgBytes == null || svgBytes.length == 0) {
|
||||
throw new IOException("SVG input is empty or null");
|
||||
}
|
||||
|
||||
log.debug("Starting SVG to PDF conversion, input size: {} bytes", svgBytes.length);
|
||||
|
||||
try {
|
||||
// 1. Load SVG using Batik
|
||||
String parser = XMLResourceDescriptor.getXMLParserClassName();
|
||||
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser);
|
||||
|
||||
SVGDocument svgDoc;
|
||||
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svgBytes)) {
|
||||
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
|
||||
}
|
||||
|
||||
// 2. Build the GVT (Graphics Vector Tree) with timeout protection
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
GraphicsNode rootNode = buildGvtWithTimeout(ctx, svgDoc);
|
||||
|
||||
// 3. Get SVG dimensions
|
||||
float width = (float) ctx.getDocumentSize().getWidth();
|
||||
float height = (float) ctx.getDocumentSize().getHeight();
|
||||
|
||||
if (width <= 0) {
|
||||
width = DEFAULT_PAGE_WIDTH;
|
||||
log.warn("SVG width not specified, using default: {}", width);
|
||||
}
|
||||
if (height <= 0) {
|
||||
height = DEFAULT_PAGE_HEIGHT;
|
||||
log.warn("SVG height not specified, using default: {}", height);
|
||||
}
|
||||
|
||||
log.debug("SVG dimensions: {}x{} points", width, height);
|
||||
|
||||
// 4. Create PDF document and render
|
||||
return renderToPdf(rootNode, width, height);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to convert SVG to PDF", e);
|
||||
throw new IOException("SVG to PDF conversion failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
|
||||
throws IOException {
|
||||
GVTBuilder builder = new GVTBuilder();
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
Callable<GraphicsNode> buildTask = () -> builder.build(ctx, svgDoc);
|
||||
Future<GraphicsNode> future = executor.submit(buildTask);
|
||||
|
||||
try {
|
||||
return future.get(RENDERING_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException(
|
||||
"SVG rendering timed out after "
|
||||
+ RENDERING_TIMEOUT_SECONDS
|
||||
+ " seconds. The SVG may be too complex.");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("SVG rendering was interrupted", e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
throw new IOException(
|
||||
"SVG rendering failed: "
|
||||
+ (cause != null ? cause.getMessage() : e.getMessage()),
|
||||
cause);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] renderToPdf(GraphicsNode rootNode, float width, float height)
|
||||
throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
|
||||
PDPage page = new PDPage(new PDRectangle(width, height));
|
||||
document.addPage(page);
|
||||
|
||||
// Create and use PdfBoxGraphics2D with proper resource management
|
||||
PdfBoxGraphics2D pdfGraphics = new PdfBoxGraphics2D(document, width, height);
|
||||
try {
|
||||
rootNode.paint(pdfGraphics);
|
||||
} finally {
|
||||
pdfGraphics.dispose();
|
||||
}
|
||||
|
||||
PDFormXObject xform = pdfGraphics.getXFormObject();
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) {
|
||||
contentStream.drawForm(xform);
|
||||
}
|
||||
|
||||
document.save(outputStream);
|
||||
|
||||
byte[] result = outputStream.toByteArray();
|
||||
log.debug("SVG to PDF conversion complete, output size: {} bytes", result.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] combineIntoPdf(List<byte[]> svgBytesList) throws IOException {
|
||||
if (svgBytesList == null || svgBytesList.isEmpty()) {
|
||||
throw new IOException("SVG list is empty or null");
|
||||
}
|
||||
|
||||
log.debug("Combining {} SVG files into single PDF", svgBytesList.size());
|
||||
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
|
||||
for (int i = 0; i < svgBytesList.size(); i++) {
|
||||
byte[] svgBytes = svgBytesList.get(i);
|
||||
if (svgBytes == null || svgBytes.length == 0) {
|
||||
log.warn("Skipping empty SVG at index {}", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
addSvgAsPage(document, svgBytes);
|
||||
log.debug("Added SVG {} of {} to combined PDF", i + 1, svgBytesList.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to add SVG {} to combined PDF: {}", i, e.getMessage());
|
||||
// Continue with other SVGs
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IOException("No SVG files were successfully added to the PDF");
|
||||
}
|
||||
|
||||
document.save(outputStream);
|
||||
byte[] result = outputStream.toByteArray();
|
||||
log.debug(
|
||||
"Combined SVG to PDF conversion complete, output size: {} bytes",
|
||||
result.length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private void addSvgAsPage(PDDocument document, byte[] svgBytes) throws IOException {
|
||||
String parser = XMLResourceDescriptor.getXMLParserClassName();
|
||||
SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser);
|
||||
|
||||
SVGDocument svgDoc;
|
||||
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svgBytes)) {
|
||||
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
|
||||
}
|
||||
|
||||
UserAgent userAgent = new UserAgentAdapter();
|
||||
DocumentLoader loader = new DocumentLoader(userAgent);
|
||||
BridgeContext ctx = new BridgeContext(userAgent, loader);
|
||||
ctx.setDynamicState(BridgeContext.DYNAMIC);
|
||||
|
||||
GraphicsNode rootNode = buildGvtWithTimeout(ctx, svgDoc);
|
||||
|
||||
float svgWidth = (float) ctx.getDocumentSize().getWidth();
|
||||
float svgHeight = (float) ctx.getDocumentSize().getHeight();
|
||||
|
||||
if (svgWidth <= 0) svgWidth = DEFAULT_PAGE_WIDTH;
|
||||
if (svgHeight <= 0) svgHeight = DEFAULT_PAGE_HEIGHT;
|
||||
|
||||
// Use SVG dimensions directly for the PDF page
|
||||
PDPage page = new PDPage(new PDRectangle(svgWidth, svgHeight));
|
||||
document.addPage(page);
|
||||
|
||||
PdfBoxGraphics2D pdfGraphics = new PdfBoxGraphics2D(document, svgWidth, svgHeight);
|
||||
try {
|
||||
rootNode.paint(pdfGraphics);
|
||||
} finally {
|
||||
pdfGraphics.dispose();
|
||||
}
|
||||
|
||||
PDFormXObject xform = pdfGraphics.getXFormObject();
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(document, page)) {
|
||||
contentStream.drawForm(xform);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user