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,280 @@
package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.SsrfProtectionService;
@Component
@RequiredArgsConstructor
@Slf4j
public class SvgSanitizer {
private static final Set<String> DANGEROUS_ELEMENTS =
Set.of("script", "foreignobject", "iframe", "object", "embed", "handler", "listener");
private static final Set<String> URL_ATTRIBUTES = Set.of("href", "xlink:href", "src", "data");
private static final Pattern JAVASCRIPT_URL_PATTERN =
Pattern.compile("^\\s*javascript\\s*:", Pattern.CASE_INSENSITIVE);
private static final Pattern DATA_SCRIPT_PATTERN =
Pattern.compile(
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
public byte[] sanitize(byte[] svgBytes) throws IOException {
if (svgBytes == null || svgBytes.length == 0) {
throw new IOException("SVG input is empty or null");
}
if (applicationProperties.getSystem().isDisableSanitize()) {
log.debug("SVG sanitization disabled by configuration");
return svgBytes;
}
try {
Document doc = parseSecurely(svgBytes);
Element root = doc.getDocumentElement();
if (root == null) {
throw new IOException("SVG document has no root element");
}
sanitizeNode(root);
byte[] result = serializeDocument(doc);
if (result == null || result.length == 0) {
throw new IOException("SVG sanitization produced empty output");
}
return result;
} catch (ParserConfigurationException | SAXException | TransformerException e) {
throw new IOException("Failed to sanitize SVG content", e);
}
}
private Document parseSecurely(byte[] svgBytes)
throws ParserConfigurationException, SAXException, IOException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(svgBytes));
}
private void sanitizeNode(Node node) {
if (node == null) {
return;
}
NodeList children = node.getChildNodes();
Set<Node> nodesToRemove = new HashSet<>();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
String localName = child.getLocalName();
if (localName == null) {
localName = child.getNodeName();
}
if (isDangerousElement(localName)) {
log.warn("Removing dangerous SVG element: {}", localName);
nodesToRemove.add(child);
} else {
sanitizeNode(child);
}
}
}
for (Node toRemove : nodesToRemove) {
node.removeChild(toRemove);
}
if (node.getNodeType() == Node.ELEMENT_NODE) {
sanitizeAttributes((Element) node);
}
}
private void sanitizeAttributes(Element element) {
NamedNodeMap attributes = element.getAttributes();
Set<String> attributesToRemove = new HashSet<>();
for (int i = 0; i < attributes.getLength(); i++) {
Node attr = attributes.item(i);
String attrName = attr.getNodeName().toLowerCase();
String attrValue = attr.getNodeValue();
if (isEventHandler(attrName)) {
log.warn("Removing event handler attribute: {}", attrName);
attributesToRemove.add(attr.getNodeName());
continue;
}
if (isUrlAttribute(attrName)) {
if (isDangerousUrl(attrValue)) {
log.warn(
"Removing dangerous URL in attribute {}: {}",
attrName,
truncateForLog(attrValue));
attributesToRemove.add(attr.getNodeName());
continue;
}
if (isExternalUrl(attrValue) && !isUrlAllowed(attrValue)) {
log.warn(
"Removing SSRF-blocked URL in attribute {}: {}",
attrName,
truncateForLog(attrValue));
attributesToRemove.add(attr.getNodeName());
}
}
}
for (String attrName : attributesToRemove) {
element.removeAttribute(attrName);
}
}
private boolean isDangerousElement(String localName) {
return DANGEROUS_ELEMENTS.contains(localName.toLowerCase());
}
private boolean isEventHandler(String attrName) {
return attrName.startsWith("on");
}
private boolean isUrlAttribute(String attrName) {
return URL_ATTRIBUTES.contains(attrName.toLowerCase())
|| attrName.toLowerCase().endsWith(":href");
}
private boolean isDangerousUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
String normalized = normalizeUrl(url);
if (JAVASCRIPT_URL_PATTERN.matcher(normalized).find()) {
return true;
}
if (DATA_SCRIPT_PATTERN.matcher(normalized).find()) {
return true;
}
return false;
}
private String normalizeUrl(String url) {
if (url == null) {
return "";
}
String result = url.trim();
result = result.replaceAll("\u0000", "");
for (int i = 0; i < 3; i++) {
try {
String decoded = URLDecoder.decode(result, StandardCharsets.UTF_8);
if (decoded.equals(result)) {
break; // No more decoding needed
}
result = decoded;
} catch (Exception e) {
log.debug("Failed to decode URL, continuing with current value", e);
break;
}
}
return result.toLowerCase();
}
private boolean isExternalUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
String normalized = normalizeUrl(url);
if (normalized.startsWith("#")) {
return false;
}
if (normalized.startsWith("data:")) {
return false;
}
return normalized.startsWith("http://")
|| normalized.startsWith("https://")
|| normalized.startsWith("//")
|| normalized.startsWith("file:");
}
private boolean isUrlAllowed(String url) {
if (ssrfProtectionService == null) {
return true;
}
return ssrfProtectionService.isUrlAllowed(url);
}
private byte[] serializeDocument(Document doc) throws TransformerException {
TransformerFactory transformerFactory = TransformerFactory.newInstance();
transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
Transformer transformer = transformerFactory.newTransformer();
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty(OutputKeys.INDENT, "no");
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
transformer.transform(new DOMSource(doc), new StreamResult(outputStream));
return outputStream.toByteArray();
}
private String truncateForLog(String value) {
if (value == null) {
return "null";
}
return value.length() > 50 ? value.substring(0, 50) + "..." : value;
}
}
+3
View File
@@ -91,6 +91,9 @@ dependencies {
// Batik
implementation 'org.apache.xmlgraphics:batik-all:1.19'
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
// TwelveMonkeys
runtimeOnly "com.twelvemonkeys.imageio:imageio-batik:$imageioVersion"
runtimeOnly "com.twelvemonkeys.imageio:imageio-bmp:$imageioVersion"
@@ -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);
}
}
}