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); }