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:
Balázs Szücs
2026-01-16 18:45:50 +00:00
committed by GitHub
parent 3e061516a5
commit cb5c2a5803
17 changed files with 1150 additions and 27 deletions
@@ -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;
}
}
}
@@ -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);
@@ -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;
}
@@ -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);
}
}
}