From cb5c2a5803ffea403d18272a9d7ce55fc2abf426 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?=
<127139797+balazs-szucs@users.noreply.github.com>
Date: Fri, 16 Jan 2026 19:45:50 +0100
Subject: [PATCH] feat(conversion): add SVG to PDF conversion functionality
(#5431)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# 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`).
---
## 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
---
.../software/common/util/SvgSanitizer.java | 280 ++++++++++++++++++
app/core/build.gradle | 3 +
.../api/converters/ConvertSvgToPDF.java | 244 +++++++++++++++
.../api/misc/OverlayImageController.java | 57 +++-
.../model/api/converters/SvgToPdfRequest.java | 29 ++
.../model/api/misc/OverlayImageRequest.java | 5 +-
.../software/SPDF/utils/SvgOverlayUtil.java | 97 ++++++
.../software/SPDF/utils/SvgToPdf.java | 234 +++++++++++++++
.../public/locales/en-GB/translation.toml | 5 +
.../annotation/shared/ImageUploader.tsx | 136 ++++++++-
.../components/annotation/tools/ImageTool.tsx | 2 +-
.../tools/convert/ConvertFromSvgSettings.tsx | 41 +++
.../tools/convert/ConvertSettings.tsx | 13 +
.../src/core/constants/convertConstants.ts | 5 +-
.../tools/convert/useConvertOperation.ts | 4 +
frontend/src/core/utils/convertUtils.ts | 20 +-
.../src/core/utils/signatureFlattening.ts | 2 +-
17 files changed, 1150 insertions(+), 27 deletions(-)
create mode 100644 app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java
create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java
create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/converters/SvgToPdfRequest.java
create mode 100644 app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java
create mode 100644 app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java
create mode 100644 frontend/src/core/components/tools/convert/ConvertFromSvgSettings.tsx
diff --git a/app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java b/app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java
new file mode 100644
index 000000000..dbee89764
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java
@@ -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 DANGEROUS_ELEMENTS =
+ Set.of("script", "foreignobject", "iframe", "object", "embed", "handler", "listener");
+ private static final Set 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 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 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;
+ }
+}
diff --git a/app/core/build.gradle b/app/core/build.gradle
index e61ef41b1..34a2f1304 100644
--- a/app/core/build.gradle
+++ b/app/core/build.gradle
@@ -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"
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java
new file mode 100644
index 000000000..0e90f01f5
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDF.java
@@ -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 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 sanitizedSvgs = new ArrayList<>();
+ List 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 handleCombinedConversion(
+ List sanitizedSvgs, List 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 handleSeparateConversion(
+ List sanitizedSvgs, List filenames) {
+ List 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 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;
+ }
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java
index 02f55a993..663c763ba 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OverlayImageController.java
@@ -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 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);
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/SvgToPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/SvgToPdfRequest.java
new file mode 100644
index 000000000..e7c9a4065
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/SvgToPdfRequest.java
@@ -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;
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OverlayImageRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OverlayImageRequest.java
index 759daa991..fd1a904ba 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OverlayImageRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OverlayImageRequest.java
@@ -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;
diff --git a/app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java b/app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java
new file mode 100644
index 000000000..253d9c687
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java
@@ -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: buildTask = () -> builder.build(ctx, svgDoc);
+ Future 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 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);
+ }
+ }
+}
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 382a5bc51..634338069 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -1295,6 +1295,10 @@ cbzDpi = "DPI for image rendering"
cbrOptions = "CBR Options"
cbrOutputOptions = "PDF to CBR Options"
cbrDpi = "DPI for image rendering"
+svgPdfOptions = "SVG to PDF Options"
+combineSvgs = "Combine SVGs into single PDF"
+combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
+svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
[convert.ebookOptions]
ebookOptions = "eBook to PDF Options"
@@ -1396,6 +1400,7 @@ applySignatures = "Apply Images"
name = "Image"
placeholder = "Upload an image"
label = "Image file"
+hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
[addImage.steps]
configure = "Configure Image"
diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx
index c95116440..ee2cdd123 100644
--- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx
+++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx
@@ -77,15 +77,26 @@ export const ImageUploader: React.FC = ({
setCurrentFile(file);
onImageChange(file);
- const originalDataUrl = await new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = (e) => resolve(e.target?.result as string);
- reader.onerror = () => reject(reader.error);
- reader.readAsDataURL(file);
- });
+ let dataUrlToProcess: string;
+
+ // Check if file is SVG
+ const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');
+
+ if (isSvg) {
+ // For SVG, convert to PNG so it can be embedded in PDF
+ dataUrlToProcess = await convertSvgToPng(file);
+ } else {
+ // For other images, read as data URL directly
+ dataUrlToProcess = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = (e) => resolve(e.target?.result as string);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+ }
- setOriginalImageData(originalDataUrl);
- await processImage(file, removeBackground);
+ setOriginalImageData(dataUrlToProcess);
+ await processImage(dataUrlToProcess, removeBackground);
} catch (error) {
console.error('Error processing image file:', error);
}
@@ -98,6 +109,115 @@ export const ImageUploader: React.FC = ({
}
};
+ // Helper function to convert SVG to PNG
+ const convertSvgToPng = async (file: File): Promise => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ try {
+ const svgText = e.target?.result as string;
+
+ // Parse SVG to get dimensions
+ const parser = new DOMParser();
+ const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
+ const svgElement = svgDoc.documentElement;
+
+ // Get SVG dimensions
+ let width = 800; // Default width
+ let height = 600; // Default height
+
+ if (svgElement.hasAttribute('width') && svgElement.hasAttribute('height')) {
+ width = parseFloat(svgElement.getAttribute('width') || '800');
+ height = parseFloat(svgElement.getAttribute('height') || '600');
+ } else if (svgElement.hasAttribute('viewBox')) {
+ const viewBox = svgElement.getAttribute('viewBox')?.split(/\s+|,/);
+ if (viewBox && viewBox.length === 4) {
+ width = parseFloat(viewBox[2]);
+ height = parseFloat(viewBox[3]);
+ }
+ }
+
+ // Ensure reasonable dimensions
+ if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) {
+ width = 800;
+ height = 600;
+ }
+
+ // Scale large SVGs down
+ const maxDimension = 2048;
+ if (width > maxDimension || height > maxDimension) {
+ const scale = Math.min(maxDimension / width, maxDimension / height);
+ width *= scale;
+ height *= scale;
+ }
+
+ console.log('Converting SVG to PNG:', { width, height });
+
+ // Create an image element to render SVG
+ const img = new Image();
+ const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+
+ img.onload = () => {
+ try {
+ // Use computed dimensions or image natural dimensions
+ const finalWidth = img.naturalWidth || img.width || width;
+ const finalHeight = img.naturalHeight || img.height || height;
+
+ console.log('Image loaded:', { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, finalWidth, finalHeight });
+
+ // Create canvas to convert to PNG
+ const canvas = document.createElement('canvas');
+ canvas.width = finalWidth;
+ canvas.height = finalHeight;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ URL.revokeObjectURL(url);
+ reject(new Error('Failed to get canvas context'));
+ return;
+ }
+
+ // Fill with white background (optional, for transparency support)
+ ctx.fillStyle = 'white';
+ ctx.fillRect(0, 0, finalWidth, finalHeight);
+
+ // Draw SVG
+ ctx.drawImage(img, 0, 0, finalWidth, finalHeight);
+ URL.revokeObjectURL(url);
+
+ // Convert canvas to PNG data URL
+ const pngDataUrl = canvas.toDataURL('image/png');
+ console.log('SVG converted to PNG successfully');
+ resolve(pngDataUrl);
+ } catch (error) {
+ URL.revokeObjectURL(url);
+ console.error('Error during canvas rendering:', error);
+ reject(error);
+ }
+ };
+
+ img.onerror = (error) => {
+ URL.revokeObjectURL(url);
+ console.error('Failed to load SVG image:', error);
+ reject(new Error('Failed to load SVG image'));
+ };
+
+ img.src = url;
+ } catch (error) {
+ console.error('Error parsing SVG:', error);
+ reject(error);
+ }
+ };
+
+ reader.onerror = () => {
+ console.error('Error reading file:', reader.error);
+ reject(reader.error);
+ };
+ reader.readAsText(file);
+ });
+ };
+
const handleBackgroundRemovalChange = async (checked: boolean) => {
if (isProcessing) return; // Prevent race conditions
setRemoveBackground(checked);
diff --git a/frontend/src/core/components/annotation/tools/ImageTool.tsx b/frontend/src/core/components/annotation/tools/ImageTool.tsx
index d9b4defec..070454696 100644
--- a/frontend/src/core/components/annotation/tools/ImageTool.tsx
+++ b/frontend/src/core/components/annotation/tools/ImageTool.tsx
@@ -59,7 +59,7 @@ export const ImageTool: React.FC = ({
disabled={disabled}
label="Upload Image"
placeholder="Select image file"
- hint="Upload a PNG, JPG, or other image file to place on the PDF"
+ hint="Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
/>
diff --git a/frontend/src/core/components/tools/convert/ConvertFromSvgSettings.tsx b/frontend/src/core/components/tools/convert/ConvertFromSvgSettings.tsx
new file mode 100644
index 000000000..eee786250
--- /dev/null
+++ b/frontend/src/core/components/tools/convert/ConvertFromSvgSettings.tsx
@@ -0,0 +1,41 @@
+import { Stack, Text, Switch } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
+
+interface ConvertFromSvgSettingsProps {
+ parameters: ConvertParameters;
+ onParameterChange: (key: K, value: ConvertParameters[K]) => void;
+ disabled?: boolean;
+}
+
+const ConvertFromSvgSettings = ({
+ parameters,
+ onParameterChange,
+ disabled = false
+}: ConvertFromSvgSettingsProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {t("convert.svgPdfOptions", "SVG to PDF Options")}:
+
+ onParameterChange('imageOptions', {
+ ...parameters.imageOptions,
+ combineImages: event.currentTarget.checked
+ })}
+ disabled={disabled}
+ />
+
+
+ {t("convert.svgVectorNote", "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size.")}
+
+
+ );
+};
+
+export default ConvertFromSvgSettings;
diff --git a/frontend/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/src/core/components/tools/convert/ConvertSettings.tsx
index 68e690a97..7ce07c8f6 100644
--- a/frontend/src/core/components/tools/convert/ConvertSettings.tsx
+++ b/frontend/src/core/components/tools/convert/ConvertSettings.tsx
@@ -20,6 +20,7 @@ import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSe
import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings";
import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings";
import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings";
+import ConvertFromSvgSettings from "@app/components/tools/convert/ConvertFromSvgSettings";
import ConvertToEpubSettings from "@app/components/tools/convert/ConvertToEpubSettings";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import {
@@ -331,6 +332,18 @@ const ConvertSettings = ({
>
) : null}
+ {/* SVG to PDF options */}
+ {parameters.fromExtension === 'svg' && parameters.toExtension === 'pdf' && (
+ <>
+
+
+ >
+ )}
+
{/* Web to PDF options */}
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? (
diff --git a/frontend/src/core/constants/convertConstants.ts b/frontend/src/core/constants/convertConstants.ts
index 6370cc6fa..6e10fb557 100644
--- a/frontend/src/core/constants/convertConstants.ts
+++ b/frontend/src/core/constants/convertConstants.ts
@@ -21,6 +21,7 @@ export const CONVERSION_ENDPOINTS = {
'office-pdf': '/api/v1/convert/file/pdf',
'pdf-image': '/api/v1/convert/pdf/img',
'image-pdf': '/api/v1/convert/img/pdf',
+ 'svg-pdf': '/api/v1/convert/svg/pdf',
'cbz-pdf': '/api/v1/convert/cbz/pdf',
'pdf-cbz': '/api/v1/convert/pdf/cbz',
'pdf-office-word': '/api/v1/convert/pdf/word',
@@ -46,6 +47,7 @@ export const ENDPOINT_NAMES = {
'office-pdf': 'file-to-pdf',
'pdf-image': 'pdf-to-img',
'image-pdf': 'img-to-pdf',
+ 'svg-pdf': 'svg-to-pdf',
'cbz-pdf': 'cbz-to-pdf',
'pdf-cbz': 'pdf-to-cbz',
'pdf-office-word': 'pdf-to-word',
@@ -171,7 +173,8 @@ export const EXTENSION_TO_ENDPOINT: Record> = {
'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' },
'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' },
'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' },
- 'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' }, 'svg': { 'pdf': 'img-to-pdf' },
+ 'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' },
+ 'svg': { 'pdf': 'svg-to-pdf' },
'html': { 'pdf': 'html-to-pdf' },
'zip': { 'pdf': 'html-to-pdf' },
'md': { 'pdf': 'markdown-to-pdf' },
diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts
index 182c0cd68..0f976ce38 100644
--- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts
+++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts
@@ -15,6 +15,8 @@ export const shouldProcessFilesSeparately = (
// Image to PDF with combineImages = false
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
+ // SVG to PDF with combineIntoSinglePdf = false
+ (parameters.fromExtension === 'svg' && parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
// PDF to image conversions (each PDF should generate its own image file)
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
// PDF to PDF/A conversions (each PDF should be processed separately)
@@ -66,6 +68,8 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
formData.append("fitOption", imageOptions.fitOption);
formData.append("colorType", imageOptions.colorType);
formData.append("autoRotate", imageOptions.autoRotate.toString());
+ } else if (fromExtension === 'svg' && toExtension === 'pdf') {
+ formData.append("combineIntoSinglePdf", imageOptions.combineImages.toString());
} else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') {
formData.append("zoom", htmlOptions.zoomLevel.toString());
} else if ((fromExtension === 'eml' || fromExtension === 'msg') && toExtension === 'pdf') {
diff --git a/frontend/src/core/utils/convertUtils.ts b/frontend/src/core/utils/convertUtils.ts
index b1e87161b..eee179d82 100644
--- a/frontend/src/core/utils/convertUtils.ts
+++ b/frontend/src/core/utils/convertUtils.ts
@@ -1,4 +1,4 @@
-import {
+import {
CONVERSION_ENDPOINTS,
ENDPOINT_NAMES,
EXTENSION_TO_ENDPOINT,
@@ -11,15 +11,15 @@ import {
*/
export const getEndpointName = (fromExtension: string, toExtension: string): string => {
if (!fromExtension || !toExtension) return '';
-
+
let endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension];
-
- // If no explicit mapping exists and we're converting to PDF,
+
+ // If no explicit mapping exists and we're converting to PDF,
// fall back to 'any' which uses file-to-pdf endpoint
if (!endpointKey && toExtension === 'pdf' && fromExtension !== 'any') {
endpointKey = EXTENSION_TO_ENDPOINT['any']?.[toExtension];
}
-
+
return endpointKey || '';
};
@@ -29,7 +29,7 @@ export const getEndpointName = (fromExtension: string, toExtension: string): str
export const getEndpointUrl = (fromExtension: string, toExtension: string): string => {
const endpointName = getEndpointName(fromExtension, toExtension);
if (!endpointName) return '';
-
+
// Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name
for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) {
if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointName) {
@@ -50,7 +50,11 @@ export const isConversionSupported = (fromExtension: string, toExtension: string
* Checks if the given extension is an image format
*/
export const isImageFormat = (extension: string): boolean => {
- return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp', 'svg'].includes(extension.toLowerCase());
+ return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp'].includes(extension.toLowerCase());
+};
+
+export const isSvgFormat = (extension: string): boolean => {
+ return extension.toLowerCase() === 'svg';
};
/**
@@ -99,4 +103,4 @@ export const getAvailableToExtensions = (fromExtension: string): Array<{value: s
return TO_FORMAT_OPTIONS.filter(option =>
supportedExtensions.includes(option.value)
);
-};
\ No newline at end of file
+};
diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts
index f4c2fbed9..540779ec3 100644
--- a/frontend/src/core/utils/signatureFlattening.ts
+++ b/frontend/src/core/utils/signatureFlattening.ts
@@ -194,7 +194,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
try {
-
// Convert data URL to bytes
const base64Data = imageDataUrl.split(',')[1];
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
@@ -206,6 +205,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
} else if (imageDataUrl.includes('data:image/png')) {
image = await pdfDoc.embedPng(imageBytes);
} else {
+ // Default to PNG for other formats (including converted SVGs)
image = await pdfDoc.embedPng(imageBytes);
}