From 438bad3aaf35f105ebaba6c2ad4fd324b7094295 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 25 Sep 2026 20:56:58 +0200 Subject: [PATCH 1/2] feat(api): add /api/v1/misc/text-to-outlines endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all text in a PDF to vector outlines via Ghostscript -dNoOutputFonts — a standard prepress step that removes font dependency and licensing issues before print. Registered in the Other and Ghostscript endpoint groups so it is disabled automatically when Ghostscript is not installed, and the controller returns a ToolRequiredException when the group is off. Refs #471 --- .../SPDF/config/EndpointConfiguration.java | 2 + .../api/misc/TextToOutlinesController.java | 110 +++++++++++++ .../misc/TextToOutlinesControllerTest.java | 154 ++++++++++++++++++ testing/cucumber/features/external.feature | 10 ++ testing/endpoints.txt | 1 + 5 files changed, 277 insertions(+) create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesController.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesControllerTest.java diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index d4cab51648e..fd245eca472 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -431,6 +431,7 @@ public void init() { addEndpointToGroup("Other", "compare"); addEndpointToGroup("Other", "view-pdf"); addEndpointToGroup("Other", "multi-tool"); + addEndpointToGroup("Other", "text-to-outlines"); addEndpointToGroup("Other", "fields"); addEndpointToGroup("Other", "modify-fields"); addEndpointToGroup("Other", "delete-fields"); @@ -582,6 +583,7 @@ public void init() { addEndpointToGroup("Ghostscript", "scanner-effect"); addEndpointToGroup("Ghostscript", "pdf-to-vector"); addEndpointToGroup("Ghostscript", "vector-to-pdf"); + addEndpointToGroup("Ghostscript", "text-to-outlines"); /* ImageMagick */ addEndpointToGroup("ImageMagick", "compress-pdf"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesController.java new file mode 100644 index 00000000000..c074eccbe44 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesController.java @@ -0,0 +1,110 @@ +package stirling.software.SPDF.controller.api.misc; + +import java.io.IOException; +import java.util.List; + +import org.springframework.core.io.Resource; +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.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.MiscApi; +import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +@MiscApi +@RequiredArgsConstructor +@Slf4j +public class TextToOutlinesController { + + private final TempFileManager tempFileManager; + private final EndpointConfiguration endpointConfiguration; + + @AutoJobPostMapping( + value = "/text-to-outlines", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + resourceWeight = ResourceWeight.MEDIUM_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) + @Operation( + summary = "Convert text to outlines", + description = + "Converts all text in the PDF to vector outlines using Ghostscript" + + " (-dNoOutputFonts), removing font dependencies for prepress" + + " workflows.") + public ResponseEntity textToOutlines(@ModelAttribute PDFFile request) + throws IOException { + if (!endpointConfiguration.isGroupEnabled("Ghostscript")) { + throw ExceptionUtils.createGhostscriptRequiredException("text-to-outlines"); + } + MultipartFile inputFile = request.getFileInput(); + if (inputFile == null || inputFile.isEmpty()) { + throw ExceptionUtils.createFileNullOrEmptyException(); + } + + TempFile tempOutputFile = null; + try (TempFile tempInputFile = tempFileManager.createManagedTempFile(".pdf")) { + inputFile.transferTo(tempInputFile.getFile()); + tempOutputFile = tempFileManager.createManagedTempFile(".pdf"); + + ProcessExecutor processExecutor = + ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT); + List command = + List.of( + "gs", + "-sDEVICE=pdfwrite", + "-dCompatibilityLevel=1.5", + "-dNoOutputFonts", + "-dNOPAUSE", + "-dQUIET", + "-dBATCH", + "-o", + tempOutputFile.getAbsolutePath(), + tempInputFile.getAbsolutePath()); + + try { + ProcessExecutorResult result = + processExecutor.runCommandWithOutputHandling(command); + String gsOutput = result.getMessages(); + ExceptionUtils.GhostscriptException criticalError = + ExceptionUtils.detectGhostscriptCriticalError(gsOutput); + if (criticalError != null) { + throw criticalError; + } + if (result.getRc() != 0) { + throw ExceptionUtils.createGhostscriptCompressionException(gsOutput); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e); + } + + TempFile out = tempOutputFile; + tempOutputFile = null; // ownership transferred to response Resource + return WebResponseUtils.pdfFileToWebResponse( + out, + GeneralUtils.generateFilename( + inputFile.getOriginalFilename(), "_outlined.pdf")); + } finally { + if (tempOutputFile != null) { + tempOutputFile.close(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesControllerTest.java new file mode 100644 index 00000000000..d79af5870dc --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/TextToOutlinesControllerTest.java @@ -0,0 +1,154 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +@ExtendWith(MockitoExtension.class) +class TextToOutlinesControllerTest { + + @TempDir Path tempDir; + @Mock private TempFileManager tempFileManager; + @Mock private EndpointConfiguration endpointConfiguration; + @InjectMocks private TextToOutlinesController controller; + + @BeforeEach + void setUp() throws IOException { + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + java.io.File f = + Files.createTempFile("test", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath()); + return tf; + }); + } + + private MockMultipartFile createPdfWithText() throws IOException { + Path pdfPath = tempDir.resolve("text.pdf"); + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream stream = new PDPageContentStream(doc, page)) { + stream.beginText(); + stream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + stream.newLineAtOffset(50, 700); + stream.showText("outlines"); + stream.endText(); + } + doc.save(pdfPath.toFile()); + } + return new MockMultipartFile( + "fileInput", + "text.pdf", + MediaType.APPLICATION_PDF_VALUE, + Files.readAllBytes(pdfPath)); + } + + private PDFFile requestFor(MockMultipartFile file) { + PDFFile request = new PDFFile(); + request.setFileInput(file); + return request; + } + + @Test + @DisplayName("Throws ToolRequiredException when Ghostscript is disabled") + void throwsWhenGhostscriptDisabled() throws IOException { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + + assertThrows( + ExceptionUtils.ToolRequiredException.class, + () -> controller.textToOutlines(requestFor(createPdfWithText()))); + } + + @Test + @DisplayName("Invokes Ghostscript with -dNoOutputFonts") + void invokesGhostscriptWithNoOutputFonts() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + + ProcessExecutor executor = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + when(result.getMessages()).thenReturn(""); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(executor); + when(executor.runCommandWithOutputHandling(any())).thenReturn(result); + + ResponseEntity response = + controller.textToOutlines(requestFor(createPdfWithText())); + + assertEquals(200, response.getStatusCode().value()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(executor).runCommandWithOutputHandling(captor.capture()); + List command = captor.getValue(); + assertTrue(command.contains("-dNoOutputFonts")); + assertTrue(command.contains("-sDEVICE=pdfwrite")); + } + } + + @Test + @DisplayName("Ghostscript failure surfaces as an exception") + void ghostscriptFailureSurfaces() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + + ProcessExecutor executor = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(1); + when(result.getMessages()).thenReturn("gs error"); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(executor); + when(executor.runCommandWithOutputHandling(any())).thenReturn(result); + + assertThrows( + ExceptionUtils.GhostscriptException.class, + () -> controller.textToOutlines(requestFor(createPdfWithText()))); + } + } +} diff --git a/testing/cucumber/features/external.feature b/testing/cucumber/features/external.feature index 32576570683..f6f493df685 100644 --- a/testing/cucumber/features/external.feature +++ b/testing/cucumber/features/external.feature @@ -10,6 +10,16 @@ Feature: API Validation And the response status code should be 200 + @text-to-outlines @positive + Scenario: Convert text to outlines + Given I generate a PDF file as "fileInput" + And the pdf contains 1 pages with random text + When I send the API request to the endpoint "/api/v1/misc/text-to-outlines" + Then the response content type should be "application/pdf" + And the response file should have size greater than 0 + And the response status code should be 200 + + @ocr @positive Scenario: Process PDF with OCR Given I generate a PDF file as "fileInput" diff --git a/testing/endpoints.txt b/testing/endpoints.txt index 1df2e53e690..b2dac21e042 100644 --- a/testing/endpoints.txt +++ b/testing/endpoints.txt @@ -15,6 +15,7 @@ /api/v1/security/add-watermark /api/v1/security/add-password /api/v1/misc/update-metadata +/api/v1/misc/text-to-outlines /api/v1/misc/show-javascript /api/v1/misc/replace-invert-pdf /api/v1/misc/repair From 90d3469d9d9d51246b71609f6903d1a995c88d57 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 25 Sep 2026 21:39:47 +0200 Subject: [PATCH 2/2] chore: regenerate tool models for text-to-outlines Keeps the committed generated models (frontend toolApiTypes/toolIO, engine tool_models/tool_io) in sync with the Java OpenAPI spec. Generated with: task tool-models --- engine/src/stirling/models/tool_io.py | 1 + engine/src/stirling/models/tool_models.py | 11 +++++++++++ frontend/editor/src/core/types/toolApiTypes.ts | 4 ++++ frontend/editor/src/core/types/toolIO.ts | 5 +++++ 4 files changed, 21 insertions(+) diff --git a/engine/src/stirling/models/tool_io.py b/engine/src/stirling/models/tool_io.py index 7de2e72caad..bb562cc0458 100644 --- a/engine/src/stirling/models/tool_io.py +++ b/engine/src/stirling/models/tool_io.py @@ -249,6 +249,7 @@ class ToolIOSpec(ApiModel): accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO ), ToolEndpoint.SCANNER_EFFECT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.TEXT_TO_OUTLINES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), ToolEndpoint.UNLOCK_PDF_FORMS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), ToolEndpoint.UPDATE_METADATA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), ToolEndpoint.ACCESSIBILITY_REPORT: ToolIOSpec( diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 9d0cf5b6bb4..b45e9a832c6 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -1640,6 +1640,12 @@ class TextRange(ApiModel): ) +class TextToOutlinesParams(ApiModel): + """ + Converts all text in the PDF to vector outlines using Ghostscript (-dNoOutputFonts), removing font dependencies for prepress workflows. Input:PDF Output:PDF Type:SISO + """ + + class TimestampPdfParams(ApiModel): """ Contacts a trusted Time Stamp Authority (TSA) server and embeds an RFC 3161 document timestamp into the PDF. Only a SHA-256 hash of the document is sent to the TSA - the PDF itself never leaves the server. Input:PDF Output:PDF Type:SISO @@ -1862,6 +1868,7 @@ class Model( | RepairParams | ReplaceInvertPdfParams | ScannerEffectParams + | TextToOutlinesParams | UnlockPdfFormsParams | UpdateMetadataParams | AccessibilityReportParams @@ -1941,6 +1948,7 @@ class Model( | RepairParams | ReplaceInvertPdfParams | ScannerEffectParams + | TextToOutlinesParams | UnlockPdfFormsParams | UpdateMetadataParams | AccessibilityReportParams @@ -2021,6 +2029,7 @@ class Model( | RepairParams | ReplaceInvertPdfParams | ScannerEffectParams + | TextToOutlinesParams | UnlockPdfFormsParams | UpdateMetadataParams | AccessibilityReportParams @@ -2102,6 +2111,7 @@ class ToolEndpoint(StrEnum): REPAIR = "/api/v1/misc/repair" REPLACE_INVERT_PDF = "/api/v1/misc/replace-invert-pdf" SCANNER_EFFECT = "/api/v1/misc/scanner-effect" + TEXT_TO_OUTLINES = "/api/v1/misc/text-to-outlines" UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms" UPDATE_METADATA = "/api/v1/misc/update-metadata" ACCESSIBILITY_REPORT = "/api/v1/security/accessibility-report" @@ -2181,6 +2191,7 @@ class ToolEndpoint(StrEnum): ToolEndpoint.REPAIR: RepairParams, ToolEndpoint.REPLACE_INVERT_PDF: ReplaceInvertPdfParams, ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams, + ToolEndpoint.TEXT_TO_OUTLINES: TextToOutlinesParams, ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams, ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams, ToolEndpoint.ACCESSIBILITY_REPORT: AccessibilityReportParams, diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index 8688d7ef3ad..24b7ee41ed5 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -812,6 +812,7 @@ export interface MetadataRequest { export type MiscDecompressPdfRequest = Record; export type MiscRepairRequest = Record; export type MiscShowJavascriptRequest = Record; +export type MiscTextToOutlinesRequest = Record; export type MiscUnlockPdfFormsRequest = Record; export interface OptimizePdfRequest { /** @@ -1636,6 +1637,7 @@ export type ToolEndpoint = | "/api/v1/misc/replace-invert-pdf" | "/api/v1/misc/scanner-effect" | "/api/v1/misc/show-javascript" + | "/api/v1/misc/text-to-outlines" | "/api/v1/misc/unlock-pdf-forms" | "/api/v1/misc/update-metadata" | "/api/v1/security/accessibility-report" @@ -1745,6 +1747,7 @@ export interface ToolApiParams { "/api/v1/misc/replace-invert-pdf": ReplaceAndInvertColorRequest; "/api/v1/misc/scanner-effect": ScannerEffectRequest; "/api/v1/misc/show-javascript": MiscShowJavascriptRequest; + "/api/v1/misc/text-to-outlines": MiscTextToOutlinesRequest; "/api/v1/misc/unlock-pdf-forms": MiscUnlockPdfFormsRequest; "/api/v1/misc/update-metadata": MetadataRequest; "/api/v1/security/accessibility-report": AccessibilityReportRequest; @@ -1855,6 +1858,7 @@ export const TOOL_ENDPOINTS = [ "/api/v1/misc/replace-invert-pdf", "/api/v1/misc/scanner-effect", "/api/v1/misc/show-javascript", + "/api/v1/misc/text-to-outlines", "/api/v1/misc/unlock-pdf-forms", "/api/v1/misc/update-metadata", "/api/v1/security/accessibility-report", diff --git a/frontend/editor/src/core/types/toolIO.ts b/frontend/editor/src/core/types/toolIO.ts index aa70c99b571..bddfdd2fe1f 100644 --- a/frontend/editor/src/core/types/toolIO.ts +++ b/frontend/editor/src/core/types/toolIO.ts @@ -711,6 +711,11 @@ export const TOOL_IO: ToolIOTable = { produces: "JAVASCRIPT", arity: "SISO", }, + "/api/v1/misc/text-to-outlines": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, "/api/v1/misc/unlock-pdf-forms": { accepts: ["PDF"], produces: "PDF",