opensource text editor (#5146)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] 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)
- [ ] I have performed a self-review of my own code
- [ ] 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)

### 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)

- [ ] 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.
This commit is contained in:
Anthony Stirling
2025-12-03 12:55:34 +00:00
committed by GitHub
parent f902e8aca9
commit bdb3c887f3
54 changed files with 33 additions and 41 deletions
@@ -0,0 +1,225 @@
package stirling.software.SPDF.controller.api.converters;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
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.StandardPdfResponse;
import stirling.software.SPDF.model.json.PdfJsonDocument;
import stirling.software.SPDF.model.json.PdfJsonMetadata;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@Slf4j
@ConvertApi
@RequiredArgsConstructor
public class ConvertPdfJsonController {
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor")
@Operation(
summary = "Convert PDF to Text Editor Format",
description =
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> convertPdfToJson(
@ModelAttribute PDFFile request,
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
throws Exception {
MultipartFile inputFile = request.getFileInput();
if (inputFile == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/text-editor/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert Text Editor Format to PDF",
description =
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool. Input:JSON Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertJsonToPdf(@ModelAttribute GeneralFile request)
throws Exception {
MultipartFile jsonFile = request.getFileInput();
if (jsonFile == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
byte[] pdfBytes = pdfJsonConversionService.convertJsonToPdf(jsonFile);
String originalName = jsonFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
@Operation(
summary = "Extract PDF metadata for text editor lazy loading",
description =
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
+ " authenticated user. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> extractPdfMetadata(@ModelAttribute PDFFile request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
if (inputFile == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
// Generate server-side UUID for job
String baseJobId = UUID.randomUUID().toString();
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
byte[] jsonBytes =
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName + "_metadata.json";
// Return jobId in response header for client
return ResponseEntity.ok()
.header("X-Job-Id", scopedJobKey)
.contentType(MediaType.APPLICATION_JSON)
.body(jsonBytes);
}
@PostMapping(
value = "/pdf/text-editor/partial/{jobId}",
consumes = MediaType.APPLICATION_JSON_VALUE)
@StandardPdfResponse
@Operation(
summary = "Apply incremental edits from text editor to a cached PDF",
description =
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
+ " Requires the PDF to have been previously cached via the text editor metadata endpoint."
+ " The jobId must be obtained from the metadata extraction endpoint.")
public ResponseEntity<byte[]> exportPartialPdf(
@PathVariable String jobId,
@RequestBody PdfJsonDocument document,
@RequestParam(value = "filename", required = false) String filename)
throws Exception {
if (document == null) {
throw ExceptionUtils.createNullArgumentException("document");
}
// Validate job ownership
validateJobAccess(jobId);
byte[] pdfBytes = pdfJsonConversionService.exportUpdatedPages(jobId, document);
String baseName =
(filename != null && !filename.isBlank())
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
: Optional.ofNullable(document.getMetadata())
.map(PdfJsonMetadata::getTitle)
.filter(title -> title != null && !title.isBlank())
.orElse("document");
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
}
@GetMapping(value = "/pdf/text-editor/page/{jobId}/{pageNumber}")
@Operation(
summary = "Extract single page from cached PDF for text editor",
description =
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
public ResponseEntity<byte[]> extractSinglePage(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
// Validate job ownership
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
String docName = "page_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@PostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@Operation(
summary = "Clear cached PDF document for text editor",
description =
"Manually clears a cached PDF document used by the text editor to free up server resources."
+ " Called automatically after 30 minutes. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
// Validate job ownership
validateJobAccess(jobId);
pdfJsonConversionService.clearCachedDocument(jobId);
return ResponseEntity.ok().build();
}
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
/**
* Validate that the current user has access to the given job.
*
* @param jobId the job identifier to validate
* @throws SecurityException if current user does not own the job
*/
private void validateJobAccess(String jobId) {
if (jobOwnershipService != null) {
jobOwnershipService.validateJobAccess(jobId);
}
// If jobOwnershipService is null (security disabled), allow all access
}
}
@@ -0,0 +1,49 @@
package stirling.software.SPDF.model.api;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PdfJsonConversionProgress {
private int percent;
private String stage;
private String message;
private boolean complete;
private Integer current; // Current item being processed (e.g., page number)
private Integer total; // Total items to process (e.g., total pages)
public static PdfJsonConversionProgress of(int percent, String stage, String message) {
return PdfJsonConversionProgress.builder()
.percent(percent)
.stage(stage)
.message(message)
.complete(false)
.build();
}
public static PdfJsonConversionProgress of(
int percent, String stage, String message, int current, int total) {
return PdfJsonConversionProgress.builder()
.percent(percent)
.stage(stage)
.message(message)
.current(current)
.total(total)
.complete(false)
.build();
}
public static PdfJsonConversionProgress complete() {
return PdfJsonConversionProgress.builder()
.percent(100)
.stage("complete")
.message("Conversion complete")
.complete(true)
.build();
}
}
@@ -0,0 +1,61 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Represents a PDF annotation (comments, highlights, stamps, etc.). Annotations often contain OCR
* text layers or other metadata not visible in content streams.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonAnnotation {
/** Annotation subtype (Text, Highlight, Link, Stamp, Widget, etc.) */
private String subtype;
/** Human-readable text content of the annotation */
private String contents;
/** Annotation rectangle [x1, y1, x2, y2] */
private List<Float> rect;
/** Annotation appearance characteristics */
private String appearanceState;
/** Color components (e.g., [r, g, b] for RGB) */
private List<Float> color;
/** Annotation flags (print, hidden, etc.) */
private Integer flags;
/** For link annotations: destination or action */
private String destination;
/** For text annotations: icon name */
private String iconName;
/** Subject/title of the annotation */
private String subject;
/** Author of the annotation */
private String author;
/** Creation date (ISO 8601 format) */
private String creationDate;
/** Modification date (ISO 8601 format) */
private String modificationDate;
/** Full annotation dictionary for lossless round-tripping */
private PdfJsonCosValue rawData;
}
@@ -0,0 +1,49 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonCosValue {
public enum Type {
NULL,
BOOLEAN,
INTEGER,
FLOAT,
NAME,
STRING,
ARRAY,
DICTIONARY,
STREAM
}
private Type type;
/**
* Holds the decoded value for primitives (boolean, integer, float, name, string). For name
* values the stored value is the PDF name literal. For string values the content is Base64
* encoded to safely transport arbitrary binaries.
*/
private Object value;
/** Reference to nested values for arrays. */
private List<PdfJsonCosValue> items;
/** Reference to nested values for dictionaries. */
private Map<String, PdfJsonCosValue> entries;
/** Stream payload when {@code type == STREAM}. */
private PdfJsonStream stream;
}
@@ -0,0 +1,34 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonDocument {
private PdfJsonMetadata metadata;
/** Optional XMP metadata packet stored as Base64. */
private String xmpMetadata;
/** Indicates that images should be loaded lazily via API rather than embedded in the JSON. */
private Boolean lazyImages;
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
@Builder.Default private List<PdfJsonPage> pages = new ArrayList<>();
/** Form fields (AcroForm) at document level */
@Builder.Default private List<PdfJsonFormField> formFields = new ArrayList<>();
}
@@ -0,0 +1,34 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonDocumentMetadata {
private PdfJsonMetadata metadata;
/** Optional XMP metadata packet stored as Base64. */
private String xmpMetadata;
/** Indicates that images should be requested lazily via the page endpoint. */
private Boolean lazyImages;
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
@Builder.Default private List<PdfJsonPageDimension> pageDimensions = new ArrayList<>();
/** Form fields (AcroForm) at document level */
@Builder.Default private List<PdfJsonFormField> formFields = new ArrayList<>();
}
@@ -0,0 +1,96 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFont {
/** PDF resource name (e.g. F1) used as the primary identifier. */
private String id;
/** Logical page number that owns this font resource. */
private Integer pageNumber;
/** Stable UID combining page number and resource for diagnostics. */
private String uid;
/** Reported PostScript/Base font name. */
private String baseName;
/** Declared subtype in the COS dictionary. */
private String subtype;
/** Encoding dictionary or name. */
private String encoding;
/** CID system info for Type0 fonts. */
private PdfJsonFontCidSystemInfo cidSystemInfo;
/** True when the original PDF embedded the font program. */
private Boolean embedded;
/** Font program bytes (TTF/OTF/CFF/PFB) encoded as Base64. */
private String program;
/** Hint describing the font program type (ttf, otf, cff, pfb, etc.). */
private String programFormat;
/** Web-optimized font program (e.g. converted TrueType) encoded as Base64. */
private String webProgram;
/** Format hint for the webProgram payload. */
private String webProgramFormat;
/** PDF-friendly font program (e.g. converted TrueType) encoded as Base64. */
private String pdfProgram;
/** Format hint for the pdfProgram payload. */
private String pdfProgramFormat;
/** Glyph metadata for Type3 fonts to enable precise text rewrites. */
private List<PdfJsonFontType3Glyph> type3Glyphs;
/** Per-strategy synthesized font payloads for Type3 normalization. */
private List<PdfJsonFontConversionCandidate> conversionCandidates;
/** ToUnicode stream encoded as Base64 when present. */
private String toUnicode;
/** Mapped Standard 14 font name when available. */
private String standard14Name;
/** Font descriptor flags copied from the source document. */
private Integer fontDescriptorFlags;
/** Font ascent in glyph units (typically 1/1000). */
private Float ascent;
/** Font descent in glyph units (typically negative). */
private Float descent;
/** Capital height when available. */
private Float capHeight;
/** x-height when available. */
private Float xHeight;
/** Italic angle reported by the font descriptor. */
private Float italicAngle;
/** Units per em extracted from the font matrix. */
private Integer unitsPerEm;
/** Serialized COS dictionary describing the original font resource. */
private PdfJsonCosValue cosDictionary;
}
@@ -0,0 +1,20 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFontCidSystemInfo {
private String registry;
private String ordering;
private Integer supplement;
}
@@ -0,0 +1,69 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFontConversionCandidate {
/** Stable identifier for the strategy that produced this candidate. */
private String strategyId;
/** Human-readable label for diagnostics and UI toggles. */
private String strategyLabel;
/** Outcome of the conversion attempt. */
private PdfJsonFontConversionStatus status;
/** Summary diagnostics or error details. */
private String message;
/** Count of glyphs successfully synthesized. */
private Integer synthesizedGlyphs;
/** Count of glyphs that could not be reproduced accurately. */
private Integer missingGlyphs;
/** Approximate width delta (in glyph units) across the test sample. */
private Double widthDelta;
/** Approximate bounding box delta (in glyph units). */
private Double bboxDelta;
/** Base64-encoded font program (typically TTF/OTF) produced by the strategy. */
private String program;
/** Format hint for {@link #program}. */
private String programFormat;
/** Web-optimized payload (e.g. TTF) for browser preview. */
private String webProgram;
/** Format for the web payload. */
private String webProgramFormat;
/** PDF-friendly payload for re-embedding during export. */
private String pdfProgram;
/** Format for the PDF payload. */
private String pdfProgramFormat;
/** Optional PNG preview of rendered glyphs (Base64). */
private String previewImage;
/** Additional structured diagnostics (JSON string). */
private String diagnostics;
/** Known unicode/codepoint coverage derived from the conversion strategy. */
private List<Integer> glyphCoverage;
}
@@ -0,0 +1,9 @@
package stirling.software.SPDF.model.json;
public enum PdfJsonFontConversionStatus {
SUCCESS,
WARNING,
FAILURE,
SKIPPED,
UNSUPPORTED
}
@@ -0,0 +1,27 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFontType3Glyph {
/** Character code used in the content stream to reference this glyph. */
private Integer charCode;
/** PostScript glyph name, when available. */
private String glyphName;
/** Unicode code point represented by this glyph, if it can be resolved. */
private Integer unicode;
/** Raw char code used in the Type3 font encoding (0-255). */
private Integer charCodeRaw;
}
@@ -0,0 +1,66 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents a PDF form field (AcroForm). */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFormField {
/** Fully qualified field name (e.g., "form1.textfield1") */
private String name;
/** Partial field name (last component) */
private String partialName;
/** Field type (Tx=text, Btn=button, Ch=choice, Sig=signature) */
private String fieldType;
/** Field value as string */
private String value;
/** Default value */
private String defaultValue;
/** Field flags (readonly, required, multiline, etc.) */
private Integer flags;
/** Alternative field name (for accessibility) */
private String alternateFieldName;
/** Mapping name (for export) */
private String mappingName;
/** Page number where field appears (1-indexed) */
private Integer pageNumber;
/** Field rectangle [x1, y1, x2, y2] on the page */
private List<Float> rect;
/** For choice fields: list of options */
private List<String> options;
/** For choice fields: selected indices */
private List<Integer> selectedIndices;
/** For button fields: whether it's checked */
private Boolean checked;
/** Font information for text fields */
private String fontName;
private Float fontSize;
/** Full field dictionary for lossless round-tripping */
private PdfJsonCosValue rawData;
}
@@ -0,0 +1,37 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonImageElement {
private String id;
private String objectName;
private Boolean inlineImage;
private Integer nativeWidth;
private Integer nativeHeight;
private Float x;
private Float y;
private Float width;
private Float height;
private Float left;
private Float right;
private Float top;
private Float bottom;
@Builder.Default private List<Float> transform = new ArrayList<>();
private Integer zOrder;
private String imageData;
private String imageFormat;
}
@@ -0,0 +1,27 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonMetadata {
private String title;
private String author;
private String subject;
private String keywords;
private String creator;
private String producer;
private String creationDate;
private String modificationDate;
private String trapped;
private Integer numberOfPages;
}
@@ -0,0 +1,34 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonPage {
private Integer pageNumber;
private Float width;
private Float height;
private Integer rotation;
@Builder.Default private List<PdfJsonTextElement> textElements = new ArrayList<>();
@Builder.Default private List<PdfJsonImageElement> imageElements = new ArrayList<>();
@Builder.Default private List<PdfJsonAnnotation> annotations = new ArrayList<>();
/** Serialized representation of the page resources dictionary. */
private PdfJsonCosValue resources;
/** Raw content streams associated with the page, preserved for lossless round-tripping. */
@Builder.Default private List<PdfJsonStream> contentStreams = new ArrayList<>();
}
@@ -0,0 +1,20 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonPageDimension {
private Integer pageNumber;
private Float width;
private Float height;
private Integer rotation;
}
@@ -0,0 +1,27 @@
package stirling.software.SPDF.model.json;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonStream {
/**
* A dictionary of entries that describe the stream metadata (Filter, DecodeParms, etc). Each
* entry is represented using {@link PdfJsonCosValue} so nested structures are supported.
*/
private Map<String, PdfJsonCosValue> dictionary;
/** Raw stream bytes in Base64 form. Data is stored exactly as it appeared in the source PDF. */
private String rawData;
}
@@ -0,0 +1,21 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonTextColor {
private String colorSpace;
private List<Float> components;
}
@@ -0,0 +1,41 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonTextElement {
private String text;
private String fontId;
private Float fontSize;
private Float fontMatrixSize;
private Float fontSizeInPt;
private Float characterSpacing;
private Float wordSpacing;
private Float spaceWidth;
private Integer zOrder;
private Float horizontalScaling;
private Float leading;
private Float rise;
private Float x;
private Float y;
private Float width;
private Float height;
private List<Float> textMatrix;
private PdfJsonTextColor fillColor;
private PdfJsonTextColor strokeColor;
private Integer renderingMode;
private Boolean fallbackUsed;
private List<Integer> charCodes;
}
@@ -0,0 +1,274 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSBoolean;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSFloat;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSNull;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonCosValue;
import stirling.software.SPDF.model.json.PdfJsonStream;
@Slf4j
@Component
public class PdfJsonCosMapper {
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
if (stream == null) {
return null;
}
return serializeStream(
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
}
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
if (cosStream == null) {
return null;
}
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
}
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
}
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
throws IOException {
if (value == null || value.getType() == null) {
return null;
}
switch (value.getType()) {
case NULL:
return COSNull.NULL;
case BOOLEAN:
if (value.getValue() instanceof Boolean bool) {
return COSBoolean.getBoolean(bool);
}
return null;
case INTEGER:
if (value.getValue() instanceof Number number) {
return COSInteger.get(number.longValue());
}
return null;
case FLOAT:
if (value.getValue() instanceof Number number) {
return new COSFloat(number.floatValue());
}
return null;
case NAME:
if (value.getValue() instanceof String name) {
return COSName.getPDFName(name);
}
return null;
case STRING:
if (value.getValue() instanceof String encoded) {
try {
byte[] bytes = Base64.getDecoder().decode(encoded);
return new COSString(bytes);
} catch (IllegalArgumentException ex) {
log.debug("Failed to decode COSString value: {}", ex.getMessage());
}
}
return null;
case ARRAY:
COSArray array = new COSArray();
if (value.getItems() != null) {
for (PdfJsonCosValue item : value.getItems()) {
COSBase entry = deserializeCosValue(item, document);
if (entry != null) {
array.add(entry);
} else {
array.add(COSNull.NULL);
}
}
}
return array;
case DICTIONARY:
COSDictionary dictionary = new COSDictionary();
if (value.getEntries() != null) {
for (Map.Entry<String, PdfJsonCosValue> entry : value.getEntries().entrySet()) {
COSName key = COSName.getPDFName(entry.getKey());
COSBase entryValue = deserializeCosValue(entry.getValue(), document);
if (entryValue != null) {
dictionary.setItem(key, entryValue);
}
}
}
return dictionary;
case STREAM:
if (value.getStream() != null) {
return buildStreamFromModel(value.getStream(), document);
}
return null;
default:
return null;
}
}
public COSStream buildStreamFromModel(PdfJsonStream streamModel, PDDocument document)
throws IOException {
if (streamModel == null) {
return null;
}
COSStream cosStream = document.getDocument().createCOSStream();
if (streamModel.getDictionary() != null) {
for (Map.Entry<String, PdfJsonCosValue> entry :
streamModel.getDictionary().entrySet()) {
COSName key = COSName.getPDFName(entry.getKey());
COSBase value = deserializeCosValue(entry.getValue(), document);
if (value != null) {
cosStream.setItem(key, value);
}
}
}
String rawData = streamModel.getRawData();
if (rawData != null && !rawData.isBlank()) {
byte[] data;
try {
data = Base64.getDecoder().decode(rawData);
} catch (IllegalArgumentException ex) {
log.debug("Invalid base64 content stream data: {}", ex.getMessage());
data = new byte[0];
}
try (OutputStream outputStream = cosStream.createRawOutputStream()) {
outputStream.write(data);
}
cosStream.setItem(COSName.LENGTH, COSInteger.get(data.length));
} else {
cosStream.setItem(COSName.LENGTH, COSInteger.get(0));
}
return cosStream;
}
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
throws IOException {
if (base == null) {
return null;
}
if (base instanceof COSObject cosObject) {
base = cosObject.getObject();
if (base == null) {
return null;
}
}
boolean complex =
base instanceof COSDictionary
|| base instanceof COSArray
|| base instanceof COSStream;
if (complex) {
if (!visited.add(base)) {
return PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.NAME)
.value("__circular__")
.build();
}
}
try {
PdfJsonCosValue.PdfJsonCosValueBuilder builder = PdfJsonCosValue.builder();
if (base instanceof COSNull) {
builder.type(PdfJsonCosValue.Type.NULL);
return builder.build();
}
if (base instanceof COSBoolean booleanValue) {
builder.type(PdfJsonCosValue.Type.BOOLEAN).value(booleanValue.getValue());
return builder.build();
}
if (base instanceof COSInteger integer) {
builder.type(PdfJsonCosValue.Type.INTEGER).value(integer.longValue());
return builder.build();
}
if (base instanceof COSFloat floatValue) {
builder.type(PdfJsonCosValue.Type.FLOAT).value(floatValue.floatValue());
return builder.build();
}
if (base instanceof COSName name) {
builder.type(PdfJsonCosValue.Type.NAME).value(name.getName());
return builder.build();
}
if (base instanceof COSString cosString) {
builder.type(PdfJsonCosValue.Type.STRING)
.value(Base64.getEncoder().encodeToString(cosString.getBytes()));
return builder.build();
}
if (base instanceof COSArray array) {
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
for (COSBase item : array) {
PdfJsonCosValue serialized = serializeCosValue(item, visited);
items.add(serialized);
}
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
return builder.build();
}
if (base instanceof COSStream stream) {
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
return builder.build();
}
if (base instanceof COSDictionary dictionary) {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
for (COSName key : dictionary.keySet()) {
PdfJsonCosValue serialized =
serializeCosValue(dictionary.getDictionaryObject(key), visited);
entries.put(key.getName(), serialized);
}
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
return builder.build();
}
return null;
} finally {
if (complex) {
visited.remove(base);
}
}
}
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
throws IOException {
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
for (COSName key : cosStream.keySet()) {
COSBase value = cosStream.getDictionaryObject(key);
PdfJsonCosValue serialized = serializeCosValue(value, visited);
if (serialized != null) {
dictionary.put(key.getName(), serialized);
}
}
String rawData = null;
try (InputStream inputStream = cosStream.createRawInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
if (inputStream != null) {
inputStream.transferTo(baos);
}
byte[] data = baos.toByteArray();
if (data.length > 0) {
rawData = Base64.getEncoder().encodeToString(data);
}
}
return PdfJsonStream.builder().dictionary(dictionary).rawData(rawData).build();
}
}
@@ -0,0 +1,576 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonFont;
@Slf4j
@Component
@RequiredArgsConstructor
public class PdfJsonFallbackFontService {
public static final String FALLBACK_FONT_ID = "fallback-noto-sans";
public static final String DEFAULT_FALLBACK_FONT_LOCATION =
"classpath:/static/fonts/NotoSans-Regular.ttf";
public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk";
public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp";
public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean";
public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic";
public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai";
// Font name aliases map PDF font names to available fallback fonts
// This provides better visual consistency when editing PDFs
private static final Map<String, String> FONT_NAME_ALIASES =
Map.ofEntries(
// Liberation fonts are metric-compatible with Microsoft core fonts
Map.entry("arial", "fallback-liberation-sans"),
Map.entry("helvetica", "fallback-liberation-sans"),
Map.entry("arimo", "fallback-liberation-sans"),
Map.entry("liberationsans", "fallback-liberation-sans"),
Map.entry("times", "fallback-liberation-serif"),
Map.entry("timesnewroman", "fallback-liberation-serif"),
Map.entry("tinos", "fallback-liberation-serif"),
Map.entry("liberationserif", "fallback-liberation-serif"),
Map.entry("courier", "fallback-liberation-mono"),
Map.entry("couriernew", "fallback-liberation-mono"),
Map.entry("cousine", "fallback-liberation-mono"),
Map.entry("liberationmono", "fallback-liberation-mono"),
// DejaVu fonts - widely used open source fonts
Map.entry("dejavu", "fallback-dejavu-sans"),
Map.entry("dejavusans", "fallback-dejavu-sans"),
Map.entry("dejavuserif", "fallback-dejavu-serif"),
Map.entry("dejavumono", "fallback-dejavu-mono"),
Map.entry("dejavusansmono", "fallback-dejavu-mono"),
// Noto Sans - Google's universal font (use as last resort generic fallback)
Map.entry("noto", "fallback-noto-sans"),
Map.entry("notosans", "fallback-noto-sans"));
private static final Map<String, FallbackFontSpec> BUILT_IN_FALLBACK_FONTS =
Map.ofEntries(
Map.entry(
FALLBACK_FONT_CJK_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansSC-Regular.ttf",
"NotoSansSC-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_JP_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansJP-Regular.ttf",
"NotoSansJP-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_KR_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansKR-Regular.ttf",
"NotoSansKR-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_AR_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansArabic-Regular.ttf",
"NotoSansArabic-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TH_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansThai-Regular.ttf",
"NotoSansThai-Regular",
"ttf")),
// Liberation Sans family
Map.entry(
"fallback-liberation-sans",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-Regular.ttf",
"LiberationSans-Regular",
"ttf")),
Map.entry(
"fallback-liberation-sans-bold",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-Bold.ttf",
"LiberationSans-Bold",
"ttf")),
Map.entry(
"fallback-liberation-sans-italic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-Italic.ttf",
"LiberationSans-Italic",
"ttf")),
Map.entry(
"fallback-liberation-sans-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-BoldItalic.ttf",
"LiberationSans-BoldItalic",
"ttf")),
// Liberation Serif family
Map.entry(
"fallback-liberation-serif",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-Regular.ttf",
"LiberationSerif-Regular",
"ttf")),
Map.entry(
"fallback-liberation-serif-bold",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-Bold.ttf",
"LiberationSerif-Bold",
"ttf")),
Map.entry(
"fallback-liberation-serif-italic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-Italic.ttf",
"LiberationSerif-Italic",
"ttf")),
Map.entry(
"fallback-liberation-serif-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-BoldItalic.ttf",
"LiberationSerif-BoldItalic",
"ttf")),
// Liberation Mono family
Map.entry(
"fallback-liberation-mono",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-Regular.ttf",
"LiberationMono-Regular",
"ttf")),
Map.entry(
"fallback-liberation-mono-bold",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-Bold.ttf",
"LiberationMono-Bold",
"ttf")),
Map.entry(
"fallback-liberation-mono-italic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-Italic.ttf",
"LiberationMono-Italic",
"ttf")),
Map.entry(
"fallback-liberation-mono-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-BoldItalic.ttf",
"LiberationMono-BoldItalic",
"ttf")),
// Noto Sans family (enhanced with weight variants)
Map.entry(
FALLBACK_FONT_ID,
new FallbackFontSpec(
DEFAULT_FALLBACK_FONT_LOCATION, "NotoSans-Regular", "ttf")),
Map.entry(
"fallback-noto-sans-bold",
new FallbackFontSpec(
"classpath:/static/fonts/NotoSans-Bold.ttf",
"NotoSans-Bold",
"ttf")),
Map.entry(
"fallback-noto-sans-italic",
new FallbackFontSpec(
"classpath:/static/fonts/NotoSans-Italic.ttf",
"NotoSans-Italic",
"ttf")),
Map.entry(
"fallback-noto-sans-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/NotoSans-BoldItalic.ttf",
"NotoSans-BoldItalic",
"ttf")),
// DejaVu Sans family
Map.entry(
"fallback-dejavu-sans",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans.ttf", "DejaVuSans", "ttf")),
Map.entry(
"fallback-dejavu-sans-bold",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans-Bold.ttf",
"DejaVuSans-Bold",
"ttf")),
Map.entry(
"fallback-dejavu-sans-oblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans-Oblique.ttf",
"DejaVuSans-Oblique",
"ttf")),
Map.entry(
"fallback-dejavu-sans-boldoblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans-BoldOblique.ttf",
"DejaVuSans-BoldOblique",
"ttf")),
// DejaVu Serif family
Map.entry(
"fallback-dejavu-serif",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif.ttf",
"DejaVuSerif",
"ttf")),
Map.entry(
"fallback-dejavu-serif-bold",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif-Bold.ttf",
"DejaVuSerif-Bold",
"ttf")),
Map.entry(
"fallback-dejavu-serif-italic",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif-Italic.ttf",
"DejaVuSerif-Italic",
"ttf")),
Map.entry(
"fallback-dejavu-serif-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif-BoldItalic.ttf",
"DejaVuSerif-BoldItalic",
"ttf")),
// DejaVu Mono family
Map.entry(
"fallback-dejavu-mono",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono.ttf",
"DejaVuSansMono",
"ttf")),
Map.entry(
"fallback-dejavu-mono-bold",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono-Bold.ttf",
"DejaVuSansMono-Bold",
"ttf")),
Map.entry(
"fallback-dejavu-mono-oblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono-Oblique.ttf",
"DejaVuSansMono-Oblique",
"ttf")),
Map.entry(
"fallback-dejavu-mono-boldoblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
"DejaVuSansMono-BoldOblique",
"ttf")));
private final ResourceLoader resourceLoader;
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
private String fallbackFontLocation;
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>();
public PdfJsonFont buildFallbackFontModel() throws IOException {
return buildFallbackFontModel(FALLBACK_FONT_ID);
}
public PdfJsonFont buildFallbackFontModel(String fallbackId) throws IOException {
FallbackFontSpec spec = getFallbackFontSpec(fallbackId);
if (spec == null) {
throw new IOException("Unknown fallback font id " + fallbackId);
}
byte[] bytes = loadFallbackFontBytes(fallbackId, spec);
String base64 = java.util.Base64.getEncoder().encodeToString(bytes);
return PdfJsonFont.builder()
.id(fallbackId)
.uid(fallbackId)
.baseName(spec.baseName())
.subtype("TrueType")
.embedded(true)
.program(base64)
.programFormat(spec.format())
.build();
}
public PDFont loadFallbackPdfFont(PDDocument document) throws IOException {
return loadFallbackPdfFont(document, FALLBACK_FONT_ID);
}
public PDFont loadFallbackPdfFont(PDDocument document, String fallbackId) throws IOException {
FallbackFontSpec spec = getFallbackFontSpec(fallbackId);
if (spec == null) {
throw new IOException("Unknown fallback font id " + fallbackId);
}
byte[] bytes = loadFallbackFontBytes(fallbackId, spec);
try (InputStream stream = new ByteArrayInputStream(bytes)) {
// Load with embedSubset=false to ensure full glyph coverage
// Fallback fonts need all glyphs available for substituting missing characters
return PDType0Font.load(document, stream, false);
}
}
public boolean canEncodeFully(PDFont font, String text) {
return canEncode(font, text);
}
public boolean canEncode(PDFont font, int codePoint) {
return canEncode(font, new String(Character.toChars(codePoint)));
}
public boolean canEncode(PDFont font, String text) {
if (font == null || text == null || text.isEmpty()) {
return false;
}
if (font instanceof PDType3Font) {
return false;
}
try {
font.encode(text);
return true;
} catch (IOException | IllegalArgumentException | UnsupportedOperationException ex) {
// Only log at debug level to reduce verbosity - summary is logged elsewhere
log.debug(
"[FONT-DEBUG] Font {} cannot encode text '{}' ({}): {}",
font != null ? font.getName() : "null",
text,
font != null ? font.getClass().getSimpleName() : "null",
ex.getMessage());
return false;
}
}
/**
* Resolve fallback font ID based on the original font name and code point. Attempts to match
* font family and weight/style for visual consistency.
*
* @param originalFontName the name of the original font (may be null)
* @param codePoint the Unicode code point that needs to be rendered
* @return fallback font ID
*/
public String resolveFallbackFontId(String originalFontName, int codePoint) {
// First try to match based on original font name for visual consistency
if (originalFontName != null && !originalFontName.isEmpty()) {
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
// remove spaces
String normalized =
originalFontName
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
.toLowerCase()
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
// "timesnewroman")
// Extract base name without weight/style suffixes
// Split on common delimiters: hyphen, underscore, comma, plus
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
String baseName = normalized.split("[-_,+]")[0];
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
if (aliasedFontId != null) {
// Detect weight and style from the normalized font name
boolean isBold = detectBold(normalized);
boolean isItalic = detectItalic(normalized);
// Apply weight/style suffix to fallback font ID
String styledFontId = applyWeightStyle(aliasedFontId, isBold, isItalic);
log.debug(
"Matched font '{}' (normalized: '{}', base: '{}', bold: {}, italic: {}) to fallback '{}'",
originalFontName,
normalized,
baseName,
isBold,
isItalic,
styledFontId);
return styledFontId;
}
}
// Fall back to Unicode-based selection
return resolveFallbackFontId(codePoint);
}
/**
* Detect if font name indicates bold weight.
*
* @param normalizedFontName lowercase font name without subset prefix or spaces
* @return true if bold weight is detected
*/
private boolean detectBold(String normalizedFontName) {
// Check for explicit bold indicators
if (normalizedFontName.contains("bold")
|| normalizedFontName.contains("heavy")
|| normalizedFontName.contains("black")) {
return true;
}
// Check for numeric weight indicators (600-900 = bold)
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
return true;
}
return false;
}
/**
* Detect if font name indicates italic/oblique style.
*
* @param normalizedFontName lowercase font name without subset prefix or spaces
* @return true if italic style is detected
*/
private boolean detectItalic(String normalizedFontName) {
return normalizedFontName.contains("italic") || normalizedFontName.contains("oblique");
}
/**
* Apply weight/style suffix to fallback font ID.
*
* <p>Weight/style variants are only applied to font families where we have the actual font
* files available. Currently supported: - Liberation Sans: Regular, Bold, Italic, BoldItalic
* (full support) - Liberation Serif: Regular, Bold, Italic, BoldItalic (full support) -
* Liberation Mono: Regular, Bold, Italic, BoldItalic (full support) - Noto Sans: Regular, Bold,
* Italic, BoldItalic (full support) - DejaVu Sans: Regular, Bold, Oblique, BoldOblique (full
* support) - DejaVu Serif: Regular, Bold, Italic, BoldItalic (full support) - DejaVu Mono:
* Regular, Bold, Oblique, BoldOblique (full support)
*
* <p>To add weight/style support for additional font families: 1. Download the font files
* (Bold, Italic, BoldItalic) to: app/core/src/main/resources/static/fonts/ 2. Register the
* variants in BUILT_IN_FALLBACK_FONTS map (see lines 63-267) 3. Update the check below to
* include the font family prefix
*
* @param baseFontId base fallback font ID (e.g., "fallback-liberation-sans")
* @param isBold true if bold weight needed
* @param isItalic true if italic style needed
* @return styled font ID (e.g., "fallback-liberation-sans-bold"), or base ID if variants not
* available
*/
private String applyWeightStyle(String baseFontId, boolean isBold, boolean isItalic) {
// Only apply weight/style to font families where we have the font files available
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
boolean isSupported =
baseFontId.startsWith("fallback-liberation-")
|| baseFontId.equals("fallback-noto-sans")
|| baseFontId.startsWith("fallback-dejavu-");
if (!isSupported) {
return baseFontId;
}
// DejaVu Sans and Mono use "oblique" instead of "italic"
boolean useOblique =
baseFontId.equals("fallback-dejavu-sans")
|| baseFontId.equals("fallback-dejavu-mono");
if (isBold && isItalic) {
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
} else if (isBold) {
return baseFontId + "-bold";
} else if (isItalic) {
return baseFontId + (useOblique ? "-oblique" : "-italic");
}
return baseFontId;
}
/**
* Resolve fallback font ID based on Unicode code point properties.
*
* @param codePoint the Unicode code point
* @return fallback font ID
*/
public String resolveFallbackFontId(int codePoint) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| block == Character.UnicodeBlock.BOPOMOFO
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) {
return FALLBACK_FONT_CJK_ID;
}
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
return switch (script) {
case HAN -> FALLBACK_FONT_CJK_ID;
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
case HANGUL -> FALLBACK_FONT_KR_ID;
case ARABIC -> FALLBACK_FONT_AR_ID;
case THAI -> FALLBACK_FONT_TH_ID;
default -> FALLBACK_FONT_ID;
};
}
public String mapUnsupportedGlyph(int codePoint) {
return switch (codePoint) {
case 0x276E -> "<";
case 0x276F -> ">";
default -> null;
};
}
private FallbackFontSpec getFallbackFontSpec(String fallbackId) {
if (FALLBACK_FONT_ID.equals(fallbackId)) {
String baseName = inferBaseName(fallbackFontLocation, "NotoSans-Regular");
String format = inferFormat(fallbackFontLocation, "ttf");
return new FallbackFontSpec(fallbackFontLocation, baseName, format);
}
return BUILT_IN_FALLBACK_FONTS.get(fallbackId);
}
private byte[] loadFallbackFontBytes(String fallbackId, FallbackFontSpec spec)
throws IOException {
if (spec == null) {
throw new IOException("No fallback font specification for " + fallbackId);
}
byte[] cached = fallbackFontCache.get(fallbackId);
if (cached != null) {
return cached;
}
Resource resource = resourceLoader.getResource(spec.resourceLocation());
if (!resource.exists()) {
throw new IOException("Fallback font resource not found at " + spec.resourceLocation());
}
try (InputStream inputStream = resource.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
inputStream.transferTo(baos);
byte[] bytes = baos.toByteArray();
fallbackFontCache.put(fallbackId, bytes);
return bytes;
}
}
private String inferBaseName(String location, String defaultName) {
if (location == null || location.isBlank()) {
return defaultName;
}
int slash = location.lastIndexOf('/');
String fileName = slash >= 0 ? location.substring(slash + 1) : location;
int dot = fileName.lastIndexOf('.');
if (dot > 0) {
fileName = fileName.substring(0, dot);
}
return fileName.isEmpty() ? defaultName : fileName;
}
private String inferFormat(String location, String defaultFormat) {
if (location == null || location.isBlank()) {
return defaultFormat;
}
int dot = location.lastIndexOf('.');
if (dot >= 0 && dot < location.length() - 1) {
return location.substring(dot + 1).toLowerCase(Locale.ROOT);
}
return defaultFormat;
}
private record FallbackFontSpec(String resourceLocation, String baseName, String format) {}
}
@@ -0,0 +1,111 @@
package stirling.software.SPDF.service.pdfjson;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.UserServiceInterface;
/**
* Service to manage job ownership and access control for PDF JSON operations. When security is
* enabled, jobs are scoped to authenticated users. When security is disabled, jobs are globally
* accessible.
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "security.enable-login", havingValue = "true", matchIfMissing = false)
public class JobOwnershipServiceImpl
implements stirling.software.common.service.JobOwnershipService {
@Autowired(required = false)
private UserServiceInterface userService;
/**
* Get the current authenticated user's identifier. Returns empty if no user is authenticated.
*
* @return Optional containing user identifier, or empty if not authenticated
*/
public Optional<String> getCurrentUserId() {
if (userService == null) {
log.debug("UserService not available");
return Optional.empty();
}
try {
String username = userService.getCurrentUsername();
if (username != null && !username.isEmpty() && !"anonymousUser".equals(username)) {
log.debug("Current authenticated user: {}", username);
return Optional.of(username);
}
} catch (Exception e) {
log.warn("Failed to get current username from UserService: {}", e.getMessage());
}
return Optional.empty();
}
/**
* Create a scoped job key that includes user ownership when security is enabled.
*
* @param jobId the base job identifier
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
*/
public String createScopedJobKey(String jobId) {
Optional<String> userId = getCurrentUserId();
if (userId.isPresent()) {
String scopedKey = userId.get() + ":" + jobId;
log.debug("Created scoped job key: {}", scopedKey);
return scopedKey;
}
log.debug("No user authenticated, using unsecured job key: {}", jobId);
return jobId;
}
/**
* Validate that the current user has access to the given job.
*
* @param scopedJobKey the scoped job key to validate
* @return true if current user owns the job or no authentication is required
* @throws SecurityException if current user does not own the job
*/
public boolean validateJobAccess(String scopedJobKey) {
Optional<String> userId = getCurrentUserId();
// If no user authenticated, allow access (backwards compatibility)
if (userId.isEmpty()) {
log.debug("No authentication required, allowing access to job: {}", scopedJobKey);
return true;
}
// Check if job key starts with current user's ID
String userPrefix = userId.get() + ":";
if (!scopedJobKey.startsWith(userPrefix)) {
log.warn(
"Access denied: User {} attempted to access job key {} which they don't own",
userId.get(),
scopedJobKey);
throw new SecurityException(
"Access denied: You do not have permission to access this job");
}
log.debug("Access granted: User {} owns job {}", userId.get(), scopedJobKey);
return true;
}
/**
* Extract the base job ID from a scoped job key.
*
* @param scopedJobKey the scoped job key
* @return the base job ID without user prefix
*/
public String extractJobId(String scopedJobKey) {
int colonIndex = scopedJobKey.indexOf(':');
if (colonIndex > 0) {
return scopedJobKey.substring(colonIndex + 1);
}
return scopedJobKey;
}
}
@@ -0,0 +1,44 @@
package stirling.software.SPDF.service.pdfjson;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* No-op implementation of job ownership service when security is disabled. All jobs are globally
* accessible without authentication.
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "security.enable-login", havingValue = "false", matchIfMissing = true)
public class NoOpJobOwnershipService
implements stirling.software.common.service.JobOwnershipService {
@Override
public Optional<String> getCurrentUserId() {
// No authentication when security is disabled
return Optional.empty();
}
@Override
public String createScopedJobKey(String jobId) {
// Jobs are not scoped to users when security is disabled
return jobId;
}
@Override
public boolean validateJobAccess(String scopedJobKey) {
// All jobs are accessible when security is disabled
log.trace("Security disabled, allowing access to job: {}", scopedJobKey);
return true;
}
@Override
public String extractJobId(String scopedJobKey) {
// No user prefix when security is disabled
return scopedJobKey;
}
}
@@ -0,0 +1,350 @@
package stirling.software.SPDF.service.pdfjson;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Base64;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
@Slf4j
@Service
@RequiredArgsConstructor
public class PdfJsonFontService {
private final TempFileManager tempFileManager;
@Getter
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
private boolean cffConversionEnabled;
@Getter
@Value("${stirling.pdf.json.cff-converter.method:python}")
private String cffConverterMethod;
@Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
private String pythonCommand;
@Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
private String pythonScript;
@Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
private String fontforgeCommand;
private volatile boolean pythonCffConverterAvailable;
private volatile boolean fontForgeCffConverterAvailable;
@PostConstruct
private void initialiseCffConverterAvailability() {
if (!cffConversionEnabled) {
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
pythonCffConverterAvailable = false;
fontForgeCffConverterAvailable = false;
return;
}
log.info("[FONT-DEBUG] CFF conversion enabled, checking tool availability...");
pythonCffConverterAvailable = isCommandAvailable(pythonCommand);
if (!pythonCffConverterAvailable) {
log.warn(
"[FONT-DEBUG] Python command '{}' not found; Python CFF conversion disabled",
pythonCommand);
} else {
log.info("[FONT-DEBUG] Python command '{}' is available", pythonCommand);
}
fontForgeCffConverterAvailable = isCommandAvailable(fontforgeCommand);
if (!fontForgeCffConverterAvailable) {
log.warn(
"[FONT-DEBUG] FontForge command '{}' not found; FontForge CFF conversion disabled",
fontforgeCommand);
} else {
log.info("[FONT-DEBUG] FontForge command '{}' is available", fontforgeCommand);
}
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod);
}
public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
log.warn(
"[FONT-DEBUG] CFF conversion skipped: enabled={}, bytes={}",
cffConversionEnabled,
fontBytes == null ? "null" : fontBytes.length);
return null;
}
log.info(
"[FONT-DEBUG] Converting CFF font: {} bytes, method: {}",
fontBytes.length,
cffConverterMethod);
if ("python".equalsIgnoreCase(cffConverterMethod)) {
if (!pythonCffConverterAvailable) {
log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion");
return null;
}
byte[] result = convertCffUsingPython(fontBytes, toUnicode);
log.debug(
"[FONT-DEBUG] Python conversion result: {}",
result == null ? "null" : result.length + " bytes");
return result;
} else if ("fontforge".equalsIgnoreCase(cffConverterMethod)) {
if (!fontForgeCffConverterAvailable) {
log.debug(
"[FONT-DEBUG] FontForge CFF converter not available, skipping conversion");
return null;
}
byte[] result = convertCffUsingFontForge(fontBytes);
log.debug(
"[FONT-DEBUG] FontForge conversion result: {}",
result == null ? "null" : result.length + " bytes");
return result;
} else {
log.debug(
"[FONT-DEBUG] Unknown CFF converter method: {}, falling back to Python",
cffConverterMethod);
if (!pythonCffConverterAvailable) {
log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion");
return null;
}
byte[] result = convertCffUsingPython(fontBytes, toUnicode);
log.debug(
"[FONT-DEBUG] Python conversion result: {}",
result == null ? "null" : result.length + " bytes");
return result;
}
}
public String detectFontFlavor(byte[] fontBytes) {
if (fontBytes == null || fontBytes.length < 4) {
return null;
}
int signature =
((fontBytes[0] & 0xFF) << 24)
| ((fontBytes[1] & 0xFF) << 16)
| ((fontBytes[2] & 0xFF) << 8)
| (fontBytes[3] & 0xFF);
if (signature == 0x00010000 || signature == 0x74727565) {
return "ttf";
}
if (signature == 0x4F54544F) {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
}
return null;
}
public String detectTrueTypeFormat(byte[] data) {
if (data == null || data.length < 4) {
return null;
}
int signature =
((data[0] & 0xFF) << 24)
| ((data[1] & 0xFF) << 16)
| ((data[2] & 0xFF) << 8)
| (data[3] & 0xFF);
if (signature == 0x00010000) {
return "ttf";
}
if (signature == 0x4F54544F) {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
}
return null;
}
public String validateFontTables(byte[] fontBytes) {
if (fontBytes == null || fontBytes.length < 12) {
return "Font program too small";
}
int numTables = ((fontBytes[4] & 0xFF) << 8) | (fontBytes[5] & 0xFF);
if (numTables <= 0 || numTables > 512) {
return "Invalid numTables: " + numTables;
}
return null;
}
private byte[] convertCffUsingPython(byte[] fontBytes, String toUnicode) {
if (!pythonCffConverterAvailable) {
log.debug("[FONT-DEBUG] Python CFF converter not available");
return null;
}
if (pythonCommand == null
|| pythonCommand.isBlank()
|| pythonScript == null
|| pythonScript.isBlank()) {
log.debug("[FONT-DEBUG] Python converter not configured");
return null;
}
log.debug(
"[FONT-DEBUG] Running Python CFF converter: command={}, script={}",
pythonCommand,
pythonScript);
try (TempFile inputFile = new TempFile(tempFileManager, ".cff");
TempFile outputFile = new TempFile(tempFileManager, ".otf");
TempFile toUnicodeFile =
toUnicode != null ? new TempFile(tempFileManager, ".tounicode") : null) {
Files.write(inputFile.getPath(), fontBytes);
if (toUnicodeFile != null) {
try {
byte[] toUnicodeBytes = Base64.getDecoder().decode(toUnicode);
Files.write(toUnicodeFile.getPath(), toUnicodeBytes);
} catch (IllegalArgumentException ex) {
log.debug(
"[FONT-DEBUG] Failed to decode ToUnicode data for CFF conversion: {}",
ex.getMessage());
return null;
}
}
String[] command =
buildPythonCommand(
inputFile.getAbsolutePath(),
outputFile.getAbsolutePath(),
toUnicodeFile != null ? toUnicodeFile.getAbsolutePath() : null);
log.debug("[FONT-DEBUG] Executing: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER)
.runCommandWithOutputHandling(java.util.Arrays.asList(command));
if (result.getRc() != 0) {
log.error(
"[FONT-DEBUG] Python CFF conversion failed with exit code: {}",
result.getRc());
log.error("[FONT-DEBUG] Stdout: {}", result.getMessages());
return null;
}
if (!Files.exists(outputFile.getPath())) {
log.error("[FONT-DEBUG] Python CFF conversion produced no output file");
return null;
}
byte[] data = Files.readAllBytes(outputFile.getPath());
if (data.length == 0) {
log.error("[FONT-DEBUG] Python CFF conversion returned empty output");
return null;
}
log.info(
"[FONT-DEBUG] Python CFF conversion succeeded: {} bytes -> {} bytes",
fontBytes.length,
data.length);
return data;
} catch (IOException | InterruptedException ex) {
if (ex instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
log.error("[FONT-DEBUG] Python CFF conversion exception: {}", ex.getMessage(), ex);
return null;
}
}
public byte[] convertCffUsingFontForge(byte[] fontBytes) {
if (!fontForgeCffConverterAvailable) {
log.debug("FontForge CFF converter not available");
return null;
}
try (TempFile inputFile = new TempFile(tempFileManager, ".cff");
TempFile outputFile = new TempFile(tempFileManager, ".ttf")) {
Files.write(inputFile.getPath(), fontBytes);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER)
.runCommandWithOutputHandling(
java.util.Arrays.asList(
fontforgeCommand,
"-lang=ff",
"-c",
"Open($1); "
+ "ScaleToEm(1000); "
+ "SelectWorthOutputting(); "
+ "SetFontOrder(2); "
+ "Reencode(\"unicode\"); "
+ "RoundToInt(); "
+ "RemoveOverlap(); "
+ "Simplify(); "
+ "CorrectDirection(); "
+ "Generate($2, \"\", 4+16+32); "
+ "Close(); "
+ "Quit()",
inputFile.getAbsolutePath(),
outputFile.getAbsolutePath()));
if (result.getRc() != 0) {
log.warn("FontForge CFF conversion failed: {}", result.getRc());
return null;
}
if (!Files.exists(outputFile.getPath())) {
log.warn("FontForge CFF conversion produced no output");
return null;
}
byte[] data = Files.readAllBytes(outputFile.getPath());
if (data.length == 0) {
log.warn("FontForge CFF conversion returned empty output");
return null;
}
return data;
} catch (IOException | InterruptedException ex) {
if (ex instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
log.warn("FontForge CFF conversion failed: {}", ex.getMessage());
return null;
}
}
private boolean isCommandAvailable(String command) {
if (command == null || command.isBlank()) {
return false;
}
try {
ProcessBuilder processBuilder = new ProcessBuilder();
if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) {
processBuilder.command("where", command);
} else {
processBuilder.command("which", command);
}
Process process = processBuilder.start();
int exitCode = process.waitFor();
return exitCode == 0;
} catch (Exception e) {
log.debug("Error checking for command {}: {}", command, e.getMessage());
return false;
}
}
private String[] buildPythonCommand(String input, String output, String toUnicode) {
if (toUnicode != null) {
return new String[] {
pythonCommand,
pythonScript,
"--input",
input,
"--output",
output,
"--to-unicode",
toUnicode
};
}
return new String[] {pythonCommand, pythonScript, "--input", input, "--output", output};
}
}
@@ -0,0 +1,474 @@
package stirling.software.SPDF.service.pdfjson;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import javax.imageio.ImageIO;
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.contentstream.operator.OperatorName;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSName;
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.PDImage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PdfJsonConversionProgress;
import stirling.software.SPDF.model.json.PdfJsonImageElement;
/**
* Service for handling PDF image operations for JSON conversion (extraction, encoding, rendering).
*/
@Service
@Slf4j
public class PdfJsonImageService {
private record EncodedImage(String base64, String format) {}
private record Bounds(float left, float right, float bottom, float top) {
float width() {
return Math.max(0f, right - left);
}
float height() {
return Math.max(0f, top - bottom);
}
}
/**
* Collects images from all pages in a PDF document.
*
* @param document The PDF document
* @param totalPages Total number of pages
* @param progress Progress callback
* @return Map of page number to list of image elements
* @throws IOException If image extraction fails
*/
public Map<Integer, List<PdfJsonImageElement>> collectImages(
PDDocument document, int totalPages, Consumer<PdfJsonConversionProgress> progress)
throws IOException {
Map<Integer, List<PdfJsonImageElement>> imagesByPage = new LinkedHashMap<>();
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
int pageNumber = 1;
for (PDPage page : document.getPages()) {
ImageCollectingEngine engine =
new ImageCollectingEngine(page, pageNumber, imagesByPage, imageCache);
engine.processPage(page);
// Update progress for image extraction (70-80%)
int imageProgress = 70 + (int) ((pageNumber / (double) totalPages) * 10);
progress.accept(
PdfJsonConversionProgress.of(
imageProgress, "images", "Extracting images", pageNumber, totalPages));
pageNumber++;
}
return imagesByPage;
}
/**
* Extracts images from a single PDF page (for on-demand lazy loading).
*
* @param document The PDF document
* @param page The specific page to extract images from
* @param pageNumber The page number (1-indexed)
* @return List of image elements for this page
* @throws IOException If image extraction fails
*/
public List<PdfJsonImageElement> extractImagesForPage(
PDDocument document, PDPage page, int pageNumber) throws IOException {
Map<Integer, List<PdfJsonImageElement>> imagesByPage = new LinkedHashMap<>();
ImageCollectingEngine engine =
new ImageCollectingEngine(page, pageNumber, imagesByPage, new IdentityHashMap<>());
engine.processPage(page);
return imagesByPage.getOrDefault(pageNumber, new ArrayList<>());
}
/**
* Draws an image element on a PDF page content stream.
*
* @param contentStream The content stream to draw on
* @param document The PDF document
* @param element The image element to draw
* @param cache Cache of previously created image XObjects
* @throws IOException If drawing fails
*/
public void drawImageElement(
PDPageContentStream contentStream,
PDDocument document,
PdfJsonImageElement element,
Map<String, PDImageXObject> cache)
throws IOException {
if (element == null || element.getImageData() == null || element.getImageData().isBlank()) {
return;
}
String cacheKey =
element.getId() != null && !element.getId().isBlank()
? element.getId()
: Integer.toHexString(System.identityHashCode(element));
PDImageXObject image = cache.get(cacheKey);
if (image == null) {
image = createImageXObject(document, element);
if (image == null) {
return;
}
cache.put(cacheKey, image);
}
List<Float> transform = element.getTransform();
if (transform != null && transform.size() == 6) {
Matrix matrix =
new Matrix(
safeFloat(transform.get(0), 1f),
safeFloat(transform.get(1), 0f),
safeFloat(transform.get(2), 0f),
safeFloat(transform.get(3), 1f),
safeFloat(transform.get(4), 0f),
safeFloat(transform.get(5), 0f));
contentStream.drawImage(image, matrix);
return;
}
float width = safeFloat(element.getWidth(), fallbackWidth(element));
float height = safeFloat(element.getHeight(), fallbackHeight(element));
if (width <= 0f) {
width = Math.max(1f, fallbackWidth(element));
}
if (height <= 0f) {
height = Math.max(1f, fallbackHeight(element));
}
float left = resolveLeft(element, width);
float bottom = resolveBottom(element, height);
contentStream.drawImage(image, left, bottom, width, height);
}
/**
* Creates a PDImageXObject from a PdfJsonImageElement.
*
* @param document The PDF document
* @param element The image element with base64 data
* @return The created image XObject
* @throws IOException If image creation fails
*/
public PDImageXObject createImageXObject(PDDocument document, PdfJsonImageElement element)
throws IOException {
byte[] data;
try {
data = Base64.getDecoder().decode(element.getImageData());
} catch (IllegalArgumentException ex) {
log.debug("Failed to decode image element: {}", ex.getMessage());
return null;
}
String name = element.getId() != null ? element.getId() : UUID.randomUUID().toString();
return PDImageXObject.createFromByteArray(document, data, name);
}
private EncodedImage encodeImage(PDImage image) {
try {
BufferedImage bufferedImage = image.getImage();
if (bufferedImage == null) {
return null;
}
String format = resolveImageFormat(image);
if (format == null || format.isBlank()) {
format = "png";
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
boolean written = ImageIO.write(bufferedImage, format, baos);
if (!written) {
if (!"png".equalsIgnoreCase(format)) {
baos.reset();
if (!ImageIO.write(bufferedImage, "png", baos)) {
return null;
}
format = "png";
} else {
return null;
}
}
return new EncodedImage(Base64.getEncoder().encodeToString(baos.toByteArray()), format);
} catch (IOException ex) {
log.debug("Failed to encode image: {}", ex.getMessage());
return null;
}
}
private String resolveImageFormat(PDImage image) {
if (image instanceof PDImageXObject xObject) {
String suffix = xObject.getSuffix();
if (suffix != null && !suffix.isBlank()) {
return suffix.toLowerCase(Locale.ROOT);
}
}
return "png";
}
private float fallbackWidth(PdfJsonImageElement element) {
if (element.getRight() != null && element.getLeft() != null) {
return Math.max(0f, element.getRight() - element.getLeft());
}
if (element.getNativeWidth() != null) {
return element.getNativeWidth();
}
return 1f;
}
private float fallbackHeight(PdfJsonImageElement element) {
if (element.getTop() != null && element.getBottom() != null) {
return Math.max(0f, element.getTop() - element.getBottom());
}
if (element.getNativeHeight() != null) {
return element.getNativeHeight();
}
return 1f;
}
private float resolveLeft(PdfJsonImageElement element, float width) {
if (element.getLeft() != null) {
return element.getLeft();
}
if (element.getX() != null) {
return element.getX();
}
if (element.getRight() != null) {
return element.getRight() - width;
}
return 0f;
}
private float resolveBottom(PdfJsonImageElement element, float height) {
if (element.getBottom() != null) {
return element.getBottom();
}
if (element.getY() != null) {
return element.getY();
}
if (element.getTop() != null) {
return element.getTop() - height;
}
return 0f;
}
private List<Float> toMatrixValues(Matrix matrix) {
List<Float> values = new ArrayList<>(6);
values.add(matrix.getValue(0, 0));
values.add(matrix.getValue(0, 1));
values.add(matrix.getValue(1, 0));
values.add(matrix.getValue(1, 1));
values.add(matrix.getValue(2, 0));
values.add(matrix.getValue(2, 1));
return values;
}
private float safeFloat(Float value, float defaultValue) {
if (value == null || Float.isNaN(value) || Float.isInfinite(value)) {
return defaultValue;
}
return value;
}
/**
* Inner engine that extends PDFGraphicsStreamEngine to collect images from PDF content streams.
*/
private class ImageCollectingEngine extends PDFGraphicsStreamEngine {
private final int pageNumber;
private final Map<Integer, List<PdfJsonImageElement>> imagesByPage;
private final Map<COSBase, EncodedImage> imageCache;
private COSName currentXObjectName;
private int imageCounter = 0;
protected ImageCollectingEngine(
PDPage page,
int pageNumber,
Map<Integer, List<PdfJsonImageElement>> imagesByPage,
Map<COSBase, EncodedImage> imageCache)
throws IOException {
super(page);
this.pageNumber = pageNumber;
this.imagesByPage = imagesByPage;
this.imageCache = imageCache;
}
@Override
public void processPage(PDPage page) throws IOException {
super.processPage(page);
}
@Override
public void drawImage(PDImage pdImage) throws IOException {
EncodedImage encoded = getOrEncodeImage(pdImage);
if (encoded == null) {
return;
}
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
Bounds bounds = computeBounds(ctm);
List<Float> matrixValues = toMatrixValues(ctm);
PdfJsonImageElement element =
PdfJsonImageElement.builder()
.id(UUID.randomUUID().toString())
.objectName(
currentXObjectName != null
? currentXObjectName.getName()
: null)
.inlineImage(!(pdImage instanceof PDImageXObject))
.nativeWidth(pdImage.getWidth())
.nativeHeight(pdImage.getHeight())
.x(bounds.left)
.y(bounds.bottom)
.width(bounds.width())
.height(bounds.height())
.left(bounds.left)
.right(bounds.right)
.top(bounds.top)
.bottom(bounds.bottom)
.transform(matrixValues)
.zOrder(-1_000_000 + imageCounter)
.imageData(encoded.base64())
.imageFormat(encoded.format())
.build();
imageCounter++;
imagesByPage.computeIfAbsent(pageNumber, key -> new ArrayList<>()).add(element);
}
@Override
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3)
throws IOException {
// Not needed for image extraction
}
@Override
public void clip(int windingRule) throws IOException {
// Not needed for image extraction
}
@Override
public void moveTo(float x, float y) throws IOException {
// Not needed for image extraction
}
@Override
public void lineTo(float x, float y) throws IOException {
// Not needed for image extraction
}
@Override
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
throws IOException {
// Not needed for image extraction
}
@Override
public Point2D getCurrentPoint() throws IOException {
return new Point2D.Float();
}
@Override
public void closePath() throws IOException {
// Not needed for image extraction
}
@Override
public void endPath() throws IOException {
// Not needed for image extraction
}
@Override
public void shadingFill(COSName shadingName) throws IOException {
// Not needed for image extraction
}
@Override
public void fillAndStrokePath(int windingRule) throws IOException {
// Not needed for image extraction
}
@Override
public void fillPath(int windingRule) throws IOException {
// Not needed for image extraction
}
@Override
public void strokePath() throws IOException {
// Not needed for image extraction
}
@Override
protected void processOperator(Operator operator, List<COSBase> operands)
throws IOException {
if (OperatorName.DRAW_OBJECT.equals(operator.getName())
&& !operands.isEmpty()
&& operands.get(0) instanceof COSName name) {
currentXObjectName = name;
}
super.processOperator(operator, operands);
currentXObjectName = null;
}
private EncodedImage getOrEncodeImage(PDImage pdImage) {
if (pdImage == null) {
return null;
}
if (pdImage instanceof PDImageXObject xObject) {
if (xObject.isStencil()) {
return encodeImage(pdImage);
}
COSBase key = xObject.getCOSObject();
EncodedImage cached = imageCache.get(key);
if (cached != null) {
return cached;
}
EncodedImage encoded = encodeImage(pdImage);
if (encoded != null) {
imageCache.put(key, encoded);
}
return encoded;
}
return encodeImage(pdImage);
}
private Bounds computeBounds(Matrix ctm) {
AffineTransform transform = ctm.createAffineTransform();
Point2D.Float p0 = new Point2D.Float(0, 0);
Point2D.Float p1 = new Point2D.Float(1, 0);
Point2D.Float p2 = new Point2D.Float(0, 1);
Point2D.Float p3 = new Point2D.Float(1, 1);
transform.transform(p0, p0);
transform.transform(p1, p1);
transform.transform(p2, p2);
transform.transform(p3, p3);
float minX = Math.min(Math.min(p0.x, p1.x), Math.min(p2.x, p3.x));
float maxX = Math.max(Math.max(p0.x, p1.x), Math.max(p2.x, p3.x));
float minY = Math.min(Math.min(p0.y, p1.y), Math.min(p2.y, p3.y));
float maxY = Math.max(Math.max(p0.y, p1.y), Math.max(p2.y, p3.y));
if (!Float.isFinite(minX) || !Float.isFinite(minY)) {
return new Bounds(0f, 0f, 0f, 0f);
}
return new Bounds(minX, maxX, minY, maxY);
}
}
}
@@ -0,0 +1,148 @@
package stirling.software.SPDF.service.pdfjson;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Base64;
import java.util.Calendar;
import java.util.Optional;
import java.util.TimeZone;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonMetadata;
/** Service for extracting and applying PDF metadata (document info and XMP) for JSON conversion. */
@Service
@Slf4j
public class PdfJsonMetadataService {
/**
* Extracts document information metadata from a PDF.
*
* @param document The PDF document
* @return Metadata model with document info
*/
public PdfJsonMetadata extractMetadata(PDDocument document) {
PdfJsonMetadata metadata = new PdfJsonMetadata();
PDDocumentInformation info = document.getDocumentInformation();
if (info != null) {
metadata.setTitle(info.getTitle());
metadata.setAuthor(info.getAuthor());
metadata.setSubject(info.getSubject());
metadata.setKeywords(info.getKeywords());
metadata.setCreator(info.getCreator());
metadata.setProducer(info.getProducer());
metadata.setCreationDate(formatCalendar(info.getCreationDate()));
metadata.setModificationDate(formatCalendar(info.getModificationDate()));
metadata.setTrapped(info.getTrapped());
}
metadata.setNumberOfPages(document.getNumberOfPages());
return metadata;
}
/**
* Extracts XMP metadata from a PDF as base64-encoded string.
*
* @param document The PDF document
* @return Base64-encoded XMP metadata, or null if not present
*/
public String extractXmpMetadata(PDDocument document) {
if (document.getDocumentCatalog() == null) {
return null;
}
PDMetadata metadata = document.getDocumentCatalog().getMetadata();
if (metadata == null) {
return null;
}
try (InputStream inputStream = metadata.createInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
inputStream.transferTo(baos);
byte[] data = baos.toByteArray();
if (data.length == 0) {
return null;
}
return Base64.getEncoder().encodeToString(data);
} catch (IOException ex) {
log.debug("Failed to extract XMP metadata: {}", ex.getMessage());
return null;
}
}
/**
* Applies metadata to a PDF document.
*
* @param document The PDF document
* @param metadata The metadata to apply
*/
public void applyMetadata(PDDocument document, PdfJsonMetadata metadata) {
if (metadata == null) {
return;
}
PDDocumentInformation info = document.getDocumentInformation();
info.setTitle(metadata.getTitle());
info.setAuthor(metadata.getAuthor());
info.setSubject(metadata.getSubject());
info.setKeywords(metadata.getKeywords());
info.setCreator(metadata.getCreator());
info.setProducer(metadata.getProducer());
if (metadata.getCreationDate() != null) {
parseInstant(metadata.getCreationDate())
.ifPresent(instant -> info.setCreationDate(toCalendar(instant)));
}
if (metadata.getModificationDate() != null) {
parseInstant(metadata.getModificationDate())
.ifPresent(instant -> info.setModificationDate(toCalendar(instant)));
}
info.setTrapped(metadata.getTrapped());
}
/**
* Applies XMP metadata to a PDF document from base64-encoded string.
*
* @param document The PDF document
* @param base64 Base64-encoded XMP metadata
*/
public void applyXmpMetadata(PDDocument document, String base64) {
if (base64 == null || base64.isBlank()) {
return;
}
try (InputStream inputStream =
new ByteArrayInputStream(Base64.getDecoder().decode(base64))) {
PDMetadata metadata = new PDMetadata(document, inputStream);
document.getDocumentCatalog().setMetadata(metadata);
} catch (IllegalArgumentException | IOException ex) {
log.debug("Failed to apply XMP metadata: {}", ex.getMessage());
}
}
private String formatCalendar(Calendar calendar) {
if (calendar == null) {
return null;
}
return calendar.toInstant().toString();
}
private Optional<Instant> parseInstant(String value) {
try {
return Optional.of(Instant.parse(value));
} catch (DateTimeParseException ex) {
log.warn("Failed to parse instant '{}': {}", value, ex.getMessage());
return Optional.empty();
}
}
private Calendar toCalendar(Instant instant) {
Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
calendar.setTimeInMillis(instant.toEpochMilli());
return calendar;
}
}
@@ -0,0 +1,308 @@
package stirling.software.SPDF.service.pdfjson;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PdfJsonConversionProgress;
import stirling.software.SPDF.model.json.PdfJsonAnnotation;
import stirling.software.SPDF.model.json.PdfJsonCosValue;
import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata;
import stirling.software.SPDF.model.json.PdfJsonFont;
import stirling.software.SPDF.model.json.PdfJsonImageElement;
import stirling.software.SPDF.model.json.PdfJsonPage;
import stirling.software.SPDF.model.json.PdfJsonPageDimension;
import stirling.software.SPDF.model.json.PdfJsonStream;
import stirling.software.SPDF.model.json.PdfJsonTextElement;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExceptionUtils;
/**
* Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce
* memory usage for large PDFs.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class PdfLazyLoadingService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final TaskManager taskManager;
private final PdfJsonMetadataService metadataService;
private final PdfJsonImageService imageService;
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>();
/**
* Stores PDF file bytes for lazy page loading. Each page is extracted on-demand by re-loading
* the PDF from bytes.
*/
@Data
private static class CachedPdfDocument {
private final byte[] pdfBytes;
private final PdfJsonDocumentMetadata metadata;
private final long timestamp;
public CachedPdfDocument(byte[] pdfBytes, PdfJsonDocumentMetadata metadata) {
this.pdfBytes = pdfBytes;
this.metadata = metadata;
this.timestamp = System.currentTimeMillis();
}
}
/**
* Extracts document metadata, fonts, and page dimensions without page content. Caches the PDF
* bytes for subsequent page requests.
*
* @param file The uploaded PDF file
* @param jobId The job ID for caching
* @param fonts Font map (will be populated)
* @param pageFontResources Page font resources map (will be populated)
* @return Serialized metadata JSON
* @throws IOException If extraction fails
*/
public byte[] extractDocumentMetadata(
MultipartFile file,
String jobId,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources)
throws IOException {
if (file == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
Consumer<PdfJsonConversionProgress> progress =
jobId != null
? (p) -> {
log.info(
"Progress: [{}%] {} - {}{}",
p.getPercent(),
p.getStage(),
p.getMessage(),
(p.getCurrent() != null && p.getTotal() != null)
? String.format(
" (%d/%d)", p.getCurrent(), p.getTotal())
: "");
reportProgressToTaskManager(jobId, p);
}
: (p) -> {};
// Read PDF bytes once for processing and caching
byte[] pdfBytes = file.getBytes();
try (PDDocument document = pdfDocumentFactory.load(pdfBytes, true)) {
int totalPages = document.getNumberOfPages();
// Build metadata response
progress.accept(PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata"));
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(metadataService.extractMetadata(document));
docMetadata.setXmpMetadata(metadataService.extractXmpMetadata(document));
docMetadata.setLazyImages(Boolean.TRUE);
List<PdfJsonFont> serializedFonts = new ArrayList<>(fonts.values());
serializedFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
docMetadata.setFonts(serializedFonts);
// Extract page dimensions
List<PdfJsonPageDimension> pageDimensions = new ArrayList<>();
int pageIndex = 0;
for (PDPage page : document.getPages()) {
PdfJsonPageDimension dim = new PdfJsonPageDimension();
dim.setPageNumber(pageIndex + 1);
PDRectangle mediaBox = page.getMediaBox();
dim.setWidth(mediaBox.getWidth());
dim.setHeight(mediaBox.getHeight());
dim.setRotation(page.getRotation());
pageDimensions.add(dim);
pageIndex++;
}
docMetadata.setPageDimensions(pageDimensions);
// Cache PDF bytes and metadata for lazy page loading
if (jobId != null) {
CachedPdfDocument cached = new CachedPdfDocument(pdfBytes, docMetadata);
documentCache.put(jobId, cached);
log.info(
"Cached PDF bytes ({} bytes) for lazy loading, jobId: {}",
pdfBytes.length,
jobId);
// Schedule cleanup after 30 minutes
scheduleDocumentCleanup(jobId);
}
progress.accept(
PdfJsonConversionProgress.of(100, "complete", "Metadata extraction complete"));
return objectMapper.writeValueAsBytes(docMetadata);
}
}
/**
* Extracts a single page from cached PDF bytes. Re-loads the PDF for each request.
*
* @param jobId The job ID
* @param pageNumber The page number (1-indexed)
* @param serializeCosValue Function to serialize COS values
* @param extractContentStreams Function to extract content streams
* @param filterImageXObjectsFromResources Function to filter image XObjects
* @param extractText Function to extract text elements for the page
* @param extractAnnotations Function to extract annotations for the page
* @return Serialized page JSON
* @throws IOException If extraction fails
*/
public byte[] extractSinglePage(
String jobId,
int pageNumber,
java.util.function.Function<COSBase, PdfJsonCosValue> serializeCosValue,
java.util.function.Function<PDPage, List<PdfJsonStream>> extractContentStreams,
java.util.function.Function<COSBase, COSBase> filterImageXObjectsFromResources,
java.util.function.BiFunction<PDDocument, Integer, List<PdfJsonTextElement>>
extractText,
java.util.function.BiFunction<PDDocument, Integer, List<PdfJsonAnnotation>>
extractAnnotations)
throws IOException {
CachedPdfDocument cached = documentCache.get(jobId);
if (cached == null) {
throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
}
int pageIndex = pageNumber - 1;
int totalPages = cached.getMetadata().getPageDimensions().size();
if (pageIndex < 0 || pageIndex >= totalPages) {
throw new IllegalArgumentException(
"Page number " + pageNumber + " out of range (1-" + totalPages + ")");
}
log.debug("Loading PDF from bytes to extract page {} (jobId: {})", pageNumber, jobId);
// Re-load PDF from cached bytes and extract the single page
try (PDDocument document = pdfDocumentFactory.load(cached.getPdfBytes(), true)) {
PDPage page = document.getPage(pageIndex);
PdfJsonPage pageModel = new PdfJsonPage();
pageModel.setPageNumber(pageNumber);
PDRectangle mediaBox = page.getMediaBox();
pageModel.setWidth(mediaBox.getWidth());
pageModel.setHeight(mediaBox.getHeight());
pageModel.setRotation(page.getRotation());
// Extract text on-demand
pageModel.setTextElements(extractText.apply(document, pageNumber));
// Extract annotations on-demand
pageModel.setAnnotations(extractAnnotations.apply(document, pageNumber));
// Extract images on-demand
List<PdfJsonImageElement> images =
imageService.extractImagesForPage(document, page, pageNumber);
pageModel.setImageElements(images);
// Extract resources and content streams
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
COSBase filteredResources = filterImageXObjectsFromResources.apply(resourcesBase);
pageModel.setResources(serializeCosValue.apply(filteredResources));
pageModel.setContentStreams(extractContentStreams.apply(page));
log.debug(
"Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}",
pageNumber,
pageModel.getTextElements().size(),
images.size(),
pageModel.getAnnotations().size(),
jobId);
return objectMapper.writeValueAsBytes(pageModel);
}
}
/** Clears a cached document. */
public void clearCachedDocument(String jobId) {
CachedPdfDocument cached = documentCache.remove(jobId);
if (cached != null) {
log.info(
"Removed cached PDF bytes ({} bytes) for jobId: {}",
cached.getPdfBytes().length,
jobId);
}
}
/** Schedules automatic cleanup of cached documents after 30 minutes. */
private void scheduleDocumentCleanup(String jobId) {
new Thread(
() -> {
try {
Thread.sleep(TimeUnit.MINUTES.toMillis(30));
clearCachedDocument(jobId);
log.info("Auto-cleaned cached document for jobId: {}", jobId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
})
.start();
}
/**
* Report progress to TaskManager for async jobs
*
* @param jobId The job ID
* @param progress The progress update
*/
private void reportProgressToTaskManager(String jobId, PdfJsonConversionProgress progress) {
try {
log.info(
"Reporting progress for job {}: {}% - {}",
jobId, progress.getPercent(), progress.getStage());
String note;
if (progress.getCurrent() != null && progress.getTotal() != null) {
note =
String.format(
"[%d%%] %s: %s (%d/%d)",
progress.getPercent(),
progress.getStage(),
progress.getMessage(),
progress.getCurrent(),
progress.getTotal());
} else {
note =
String.format(
"[%d%%] %s: %s",
progress.getPercent(), progress.getStage(), progress.getMessage());
}
boolean added = taskManager.addNote(jobId, note);
if (!added) {
log.warn("Failed to add note - job {} not found in TaskManager", jobId);
} else {
log.info("Successfully added progress note for job {}: {}", jobId, note);
}
} catch (Exception e) {
log.error("Exception reporting progress for job {}: {}", jobId, e.getMessage(), e);
}
}
}
@@ -0,0 +1,17 @@
package stirling.software.SPDF.service.pdfjson.type3;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Type3ConversionRequest {
private final PDDocument document;
private final PDType3Font font;
private final String fontId;
private final int pageNumber;
private final String fontUid;
}
@@ -0,0 +1,32 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
public interface Type3ConversionStrategy {
/** Unique identifier used when reporting results. */
String getId();
/** Human-readable label for UI toggles or logs. */
String getLabel();
/** True when the underlying tooling is usable on this host. */
boolean isAvailable();
/** Quick predicate to avoid running on unsupported Type3 shapes. */
default boolean supports(Type3ConversionRequest request, Type3GlyphContext context)
throws IOException {
return request != null && request.getFont() != null;
}
/**
* Attempt to synthesise a font program for the supplied Type3 font.
*
* @param request contextual information for the conversion attempt
* @return a candidate describing the outcome, never {@code null}
*/
PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException;
}
@@ -0,0 +1,133 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus;
@Slf4j
@Service
@RequiredArgsConstructor
public class Type3FontConversionService {
private final List<Type3ConversionStrategy> strategies;
private final Type3GlyphExtractor glyphExtractor;
public List<PdfJsonFontConversionCandidate> synthesize(Type3ConversionRequest request) {
if (request == null || request.getFont() == null) {
return Collections.emptyList();
}
if (strategies == null || strategies.isEmpty()) {
log.debug(
"[TYPE3] No conversion strategies registered for font {}", request.getFontId());
return Collections.emptyList();
}
List<PdfJsonFontConversionCandidate> candidates = new ArrayList<>();
Type3GlyphContext glyphContext = null;
for (Type3ConversionStrategy strategy : strategies) {
if (strategy == null) {
continue;
}
PdfJsonFontConversionCandidate candidate =
runStrategy(
strategy,
request,
glyphContext == null
? (glyphContext =
new Type3GlyphContext(request, glyphExtractor))
: glyphContext);
if (candidate != null) {
candidates.add(candidate);
}
}
return candidates;
}
private PdfJsonFontConversionCandidate runStrategy(
Type3ConversionStrategy strategy,
Type3ConversionRequest request,
Type3GlyphContext glyphContext) {
if (!strategy.isAvailable()) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.SKIPPED)
.message("Strategy unavailable on current host")
.build();
}
try {
if (!strategy.supports(request, glyphContext)) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
.message("Font not supported by strategy")
.build();
}
} catch (IOException supportCheckException) {
log.warn(
"[TYPE3] Strategy {} support check failed for font {}: {}",
strategy.getId(),
request.getFontUid(),
supportCheckException.getMessage(),
supportCheckException);
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
.message("Support check failed: " + supportCheckException.getMessage())
.build();
}
try {
PdfJsonFontConversionCandidate result = strategy.convert(request, glyphContext);
if (result == null) {
log.info(
"[TYPE3] Strategy {} returned null result for font {}",
strategy.getId(),
request.getFontUid());
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message("Strategy returned null result")
.build();
}
if (result.getStrategyId() == null) {
result.setStrategyId(strategy.getId());
}
if (result.getStrategyLabel() == null) {
result.setStrategyLabel(strategy.getLabel());
}
log.debug(
"[TYPE3] Strategy {} finished with status {} (message: {}) for font {}",
strategy.getId(),
result.getStatus(),
result.getMessage(),
request.getFontUid());
return result;
} catch (IOException ex) {
log.warn(
"[TYPE3] Strategy {} failed for font {}: {}",
strategy.getId(),
request.getFontUid(),
ex.getMessage(),
ex);
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message(ex.getMessage())
.build();
}
}
}
@@ -0,0 +1,218 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSNumber;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.font.encoding.Encoding;
import org.apache.pdfbox.util.Matrix;
/**
* Computes a reproducible hash for Type3 fonts so we can match them against a pre-built library of
* converted programs. The signature intentionally combines multiple aspects of the font (encoding,
* CharProc streams, glyph widths, font metrics) to minimise collisions between unrelated fonts that
* coincidentally share glyph names.
*/
public final class Type3FontSignatureCalculator {
private Type3FontSignatureCalculator() {}
public static String computeSignature(PDType3Font font) throws IOException {
if (font == null) {
return null;
}
MessageDigest digest = newDigest();
updateMatrix(digest, font.getFontMatrix());
updateRectangle(digest, font.getFontBBox());
updateEncoding(digest, font.getEncoding());
updateCharProcs(digest, font);
byte[] hash = digest.digest();
return "sha256:" + toHex(hash);
}
private static void updateEncoding(MessageDigest digest, Encoding encoding) {
if (encoding == null) {
updateInt(digest, -1);
return;
}
for (int code = 0; code <= 0xFF; code++) {
String name = encoding.getName(code);
if (name != null) {
updateInt(digest, code);
updateString(digest, name);
}
}
}
private static void updateCharProcs(MessageDigest digest, PDType3Font font) throws IOException {
COSDictionary charProcs =
(COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS);
if (charProcs == null || charProcs.size() == 0) {
updateInt(digest, 0);
return;
}
List<COSName> glyphNames = new ArrayList<>(charProcs.keySet());
glyphNames.sort(Comparator.comparing(COSName::getName, String.CASE_INSENSITIVE_ORDER));
for (COSName glyphName : glyphNames) {
updateString(digest, glyphName.getName());
int code = resolveCharCode(font, glyphName.getName());
updateInt(digest, code);
if (code >= 0) {
try {
updateFloat(digest, font.getWidthFromFont(code));
} catch (IOException ignored) {
updateFloat(digest, 0f);
}
} else {
updateFloat(digest, 0f);
}
COSStream stream =
charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream
? cosStream
: null;
if (stream != null) {
byte[] payload = readAllBytes(stream);
updateInt(digest, payload.length);
digest.update(payload);
PDType3CharProc charProc = new PDType3CharProc(font, stream);
updateRectangle(digest, extractGlyphBoundingBox(font, charProc));
} else {
updateInt(digest, -1);
}
}
updateInt(digest, glyphNames.size());
}
private static byte[] readAllBytes(COSStream stream) throws IOException {
try (InputStream inputStream = stream.createInputStream()) {
return inputStream.readAllBytes();
}
}
private static COSArray extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) {
if (charProc == null) {
return null;
}
COSStream stream = charProc.getCOSObject();
if (stream != null) {
COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX);
if (bboxArray != null && bboxArray.size() == 4) {
return bboxArray;
}
}
return font.getCOSObject().getCOSArray(COSName.BBOX);
}
private static int resolveCharCode(PDType3Font font, String glyphName) {
if (glyphName == null || font.getEncoding() == null) {
return -1;
}
Encoding encoding = font.getEncoding();
for (int code = 0; code <= 0xFF; code++) {
String name = encoding.getName(code);
if (glyphName.equals(name)) {
return code;
}
}
return -1;
}
private static void updateMatrix(MessageDigest digest, Matrix matrix) {
if (matrix == null) {
updateInt(digest, -1);
return;
}
float[][] values = matrix.getValues();
updateInt(digest, values.length);
for (float[] row : values) {
if (row == null) {
updateInt(digest, -1);
continue;
}
updateInt(digest, row.length);
for (float value : row) {
updateFloat(digest, value);
}
}
}
private static void updateRectangle(MessageDigest digest, PDRectangle rectangle) {
if (rectangle == null) {
updateInt(digest, -1);
return;
}
updateFloat(digest, rectangle.getLowerLeftX());
updateFloat(digest, rectangle.getLowerLeftY());
updateFloat(digest, rectangle.getUpperRightX());
updateFloat(digest, rectangle.getUpperRightY());
}
private static void updateRectangle(MessageDigest digest, COSArray array) {
if (array == null) {
updateInt(digest, -1);
return;
}
updateInt(digest, array.size());
for (int i = 0; i < array.size(); i++) {
COSBase value = array.getObject(i);
if (value instanceof COSNumber number) {
updateFloat(digest, number.floatValue());
} else {
updateFloat(digest, 0f);
}
}
}
private static void updateString(MessageDigest digest, String value) {
if (value == null) {
updateInt(digest, -1);
return;
}
byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
updateInt(digest, bytes.length);
digest.update(bytes);
}
private static void updateInt(MessageDigest digest, int value) {
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
}
private static void updateFloat(MessageDigest digest, float value) {
if (Float.isNaN(value) || Float.isInfinite(value)) {
value = 0f;
}
digest.update(ByteBuffer.allocate(Float.BYTES).putFloat(value).array());
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("Missing SHA-256 MessageDigest", ex);
}
}
private static String toHex(byte[] bytes) {
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte value : bytes) {
builder.append(String.format(Locale.ROOT, "%02x", Byte.toUnsignedInt(value)));
}
return builder.toString();
}
}
@@ -0,0 +1,38 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
class Type3GlyphContext {
private final Type3ConversionRequest request;
private final Type3GlyphExtractor extractor;
private final AtomicReference<List<Type3GlyphOutline>> glyphs = new AtomicReference<>();
Type3GlyphContext(Type3ConversionRequest request, Type3GlyphExtractor extractor) {
this.request = request;
this.extractor = extractor;
}
public List<Type3GlyphOutline> getGlyphs() throws IOException {
List<Type3GlyphOutline> cached = glyphs.get();
if (cached == null) {
cached =
extractor.extractGlyphs(
request.getDocument(),
request.getFont(),
request.getFontId(),
request.getPageNumber());
glyphs.compareAndSet(null, cached);
}
return cached;
}
public PDType3Font getFont() {
return request.getFont();
}
}
@@ -0,0 +1,126 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.awt.geom.GeneralPath;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
@Slf4j
@Component
public class Type3GlyphExtractor {
public List<Type3GlyphOutline> extractGlyphs(
PDDocument document, PDType3Font font, String fontId, int pageNumber)
throws IOException {
Objects.requireNonNull(font, "font");
COSDictionary charProcs =
(COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS);
if (charProcs == null || charProcs.size() == 0) {
return List.of();
}
List<Type3GlyphOutline> outlines = new ArrayList<>();
for (COSName glyphName : charProcs.keySet()) {
COSStream stream =
charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream
? cosStream
: null;
if (stream == null) {
continue;
}
PDType3CharProc charProc = new PDType3CharProc(font, stream);
outlines.add(analyseGlyph(document, font, glyphName, charProc, fontId, pageNumber));
}
return outlines;
}
private Type3GlyphOutline analyseGlyph(
PDDocument document,
PDType3Font font,
COSName glyphName,
PDType3CharProc charProc,
String fontId,
int pageNumber)
throws IOException {
int code = resolveCharCode(font, glyphName.getName());
float advanceWidth = 0f;
if (code >= 0) {
advanceWidth = font.getWidthFromFont(code);
}
PDRectangle glyphBBox = extractGlyphBoundingBox(font, charProc);
PDRectangle bbox = font.getFontBBox();
GlyphGraphicsExtractor extractor =
new GlyphGraphicsExtractor(new PDPage(bbox != null ? bbox : new PDRectangle()));
extractor.process(charProc);
GeneralPath outline = extractor.getAccumulatedPath();
Integer unicodeValue = null;
if (code >= 0) {
String unicode = font.toUnicode(code);
if (unicode != null && !unicode.isEmpty()) {
unicodeValue = unicode.codePointAt(0);
} else {
unicodeValue = code;
}
}
return Type3GlyphOutline.builder()
.glyphName(glyphName.getName())
.charCode(code)
.advanceWidth(advanceWidth)
.boundingBox(glyphBBox)
.outline(outline)
.hasFill(extractor.isSawFill())
.hasStroke(extractor.isSawStroke())
.hasImages(extractor.isSawImage())
.hasText(extractor.isSawText())
.hasShading(extractor.isSawShading())
.warnings(extractor.getWarnings())
.unicode(unicodeValue)
.build();
}
private PDRectangle extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) {
COSStream stream = charProc != null ? charProc.getCOSObject() : null;
if (stream != null) {
COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX);
if (bboxArray != null && bboxArray.size() == 4) {
return new PDRectangle(bboxArray);
}
}
return font.getFontBBox();
}
private int resolveCharCode(PDType3Font font, String glyphName) {
if (glyphName == null || font.getEncoding() == null) {
return -1;
}
for (int code = 0; code <= 0xFF; code++) {
String name = font.getEncoding().getName(code);
if (glyphName.equals(name)) {
return code;
}
}
return -1;
}
private static final class GlyphGraphicsExtractor extends Type3GraphicsEngine {
GlyphGraphicsExtractor(PDPage page) {
super(page);
}
}
}
@@ -0,0 +1,164 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.awt.geom.GeneralPath;
import java.awt.geom.Point2D;
import java.io.IOException;
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
import org.apache.pdfbox.util.Matrix;
import org.apache.pdfbox.util.Vector;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@Slf4j
class Type3GraphicsEngine extends PDFGraphicsStreamEngine {
private final GeneralPath accumulatedPath = new GeneralPath();
private final GeneralPath linePath = new GeneralPath();
private final Point2D.Float currentPoint = new Point2D.Float();
private boolean hasCurrentPoint;
@Getter private boolean sawStroke;
@Getter private boolean sawFill;
@Getter private boolean sawImage;
@Getter private boolean sawText;
@Getter private boolean sawShading;
@Getter private String warnings;
protected Type3GraphicsEngine(PDPage page) {
super(page);
}
public GeneralPath getAccumulatedPath() {
return (GeneralPath) accumulatedPath.clone();
}
public void process(PDType3CharProc charProc) throws IOException {
accumulatedPath.reset();
linePath.reset();
sawStroke = false;
sawFill = false;
sawImage = false;
sawText = false;
sawShading = false;
warnings = null;
if (charProc != null) {
processChildStream(charProc, getPage());
}
}
@Override
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) throws IOException {
moveTo((float) p0.getX(), (float) p0.getY());
lineTo((float) p1.getX(), (float) p1.getY());
lineTo((float) p2.getX(), (float) p2.getY());
lineTo((float) p3.getX(), (float) p3.getY());
closePath();
}
@Override
public void drawImage(PDImage pdImage) throws IOException {
sawImage = true;
}
@Override
public void shadingFill(COSName shadingName) throws IOException {
sawShading = true;
}
@Override
public void strokePath() throws IOException {
accumulatedPath.append(linePath, false);
linePath.reset();
sawStroke = true;
}
@Override
public void fillPath(int windingRule) throws IOException {
linePath.setWindingRule(
windingRule == 0 ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO);
accumulatedPath.append(linePath, false);
linePath.reset();
sawFill = true;
}
@Override
public void fillAndStrokePath(int windingRule) throws IOException {
fillPath(windingRule);
sawStroke = true;
}
@Override
public void clip(int windingRule) throws IOException {
// ignore
}
@Override
public void moveTo(float x, float y) throws IOException {
linePath.moveTo(x, y);
currentPoint.setLocation(x, y);
hasCurrentPoint = true;
}
@Override
public void lineTo(float x, float y) throws IOException {
linePath.lineTo(x, y);
currentPoint.setLocation(x, y);
hasCurrentPoint = true;
}
@Override
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
throws IOException {
linePath.curveTo(x1, y1, x2, y2, x3, y3);
currentPoint.setLocation(x3, y3);
hasCurrentPoint = true;
}
@Override
public Point2D getCurrentPoint() throws IOException {
return hasCurrentPoint ? (Point2D) currentPoint.clone() : null;
}
@Override
public void closePath() throws IOException {
linePath.closePath();
}
@Override
public void endPath() throws IOException {
linePath.reset();
hasCurrentPoint = false;
}
@Override
protected void showText(byte[] string) throws IOException {
sawText = true;
super.showText(string);
}
@Override
protected void showFontGlyph(
Matrix textRenderingMatrix, PDFont font, int code, Vector displacement)
throws IOException {
sawText = true;
super.showFontGlyph(textRenderingMatrix, font, code, displacement);
}
@Override
protected void processOperator(
Operator operator, java.util.List<org.apache.pdfbox.cos.COSBase> operands)
throws IOException {
if ("cm".equals(operator.getName())) {
warnings =
warnings == null ? "Encountered CTM concatenation" : warnings + "; CTM concat";
}
super.processOperator(operator, operands);
}
}
@@ -0,0 +1,113 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibrary;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryEntry;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryMatch;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayload;
@Slf4j
@Component
@Order(0)
@RequiredArgsConstructor
public class Type3LibraryStrategy implements Type3ConversionStrategy {
private final Type3FontLibrary fontLibrary;
@Value("${stirling.pdf.json.type3.library.enabled:true}")
private boolean enabled;
@Override
public String getId() {
return "type3-library";
}
@Override
public String getLabel() {
return "Type3 Font Library";
}
@Override
public boolean isAvailable() {
return enabled && fontLibrary != null && fontLibrary.isLoaded();
}
@Override
public PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
if (request == null || request.getFont() == null) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message("No font supplied")
.build();
}
if (!isAvailable()) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.SKIPPED)
.message("Library disabled")
.build();
}
Type3FontLibraryMatch match = fontLibrary.match(request.getFont(), request.getFontUid());
if (match == null || match.getEntry() == null) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
.message("No library entry found")
.build();
}
Type3FontLibraryEntry entry = match.getEntry();
if (!entry.hasAnyPayload()) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message("Library entry has no payloads")
.build();
}
String message =
String.format(
"Matched %s via %s",
entry.getLabel(),
match.getMatchType() != null ? match.getMatchType() : "alias");
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.SUCCESS)
.program(toBase64(entry.getProgram()))
.programFormat(toFormat(entry.getProgram()))
.webProgram(toBase64(entry.getWebProgram()))
.webProgramFormat(toFormat(entry.getWebProgram()))
.pdfProgram(toBase64(entry.getPdfProgram()))
.pdfProgramFormat(toFormat(entry.getPdfProgram()))
.glyphCoverage(entry.getGlyphCoverage())
.message(message)
.build();
}
private String toBase64(Type3FontLibraryPayload payload) {
return payload != null ? payload.getBase64() : null;
}
private String toFormat(Type3FontLibraryPayload payload) {
return payload != null ? payload.getFormat() : null;
}
}
@@ -0,0 +1,299 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
@Slf4j
@Component
@RequiredArgsConstructor
public class Type3FontLibrary {
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
@Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}")
private String indexLocation;
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
private final Map<String, Type3FontLibraryEntry> aliasIndex = new ConcurrentHashMap<>();
private List<Type3FontLibraryEntry> entries = List.of();
@jakarta.annotation.PostConstruct
void initialise() {
Resource resource = resourceLoader.getResource(indexLocation);
if (!resource.exists()) {
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
entries = List.of();
return;
}
try (InputStream inputStream = resource.getInputStream()) {
List<RawEntry> rawEntries =
objectMapper.readValue(inputStream, new TypeReference<List<RawEntry>>() {});
List<Type3FontLibraryEntry> loaded = new ArrayList<>();
for (RawEntry rawEntry : rawEntries) {
Type3FontLibraryEntry entry = toEntry(rawEntry);
if (entry != null && entry.hasAnyPayload()) {
loaded.add(entry);
}
}
entries = Collections.unmodifiableList(loaded);
signatureIndex.clear();
aliasIndex.clear();
for (Type3FontLibraryEntry entry : entries) {
if (entry.getSignatures() != null) {
for (String signature : entry.getSignatures()) {
if (signature == null) {
continue;
}
String key = signature.toLowerCase(Locale.ROOT);
signatureIndex.putIfAbsent(key, entry);
}
}
if (entry.getAliases() != null) {
for (String alias : entry.getAliases()) {
String normalized = normalizeAlias(alias);
if (normalized != null) {
aliasIndex.putIfAbsent(normalized, entry);
}
}
}
}
log.info(
"[TYPE3] Loaded {} Type3 library entries (signatures={}, aliases={}) from {}",
entries.size(),
signatureIndex.size(),
aliasIndex.size(),
indexLocation);
} catch (IOException ex) {
log.warn(
"[TYPE3] Failed to load Type3 library index {}: {}",
indexLocation,
ex.getMessage(),
ex);
entries = List.of();
signatureIndex.clear();
aliasIndex.clear();
}
}
public boolean isLoaded() {
return !entries.isEmpty();
}
public Type3FontLibraryMatch match(PDType3Font font, String fontUid) throws IOException {
if (font == null || entries.isEmpty()) {
return null;
}
String signature = Type3FontSignatureCalculator.computeSignature(font);
if (signature != null) {
Type3FontLibraryEntry entry = signatureIndex.get(signature.toLowerCase(Locale.ROOT));
if (entry != null) {
log.debug(
"[TYPE3] Matched Type3 font {} to library entry {} via signature {}",
fontUid,
entry.getId(),
signature);
return Type3FontLibraryMatch.builder()
.entry(entry)
.matchType("signature")
.signature(signature)
.build();
}
log.debug(
"[TYPE3] No library entry for signature {} (font {})",
signature,
fontUid != null ? fontUid : font.getName());
}
String aliasKey = normalizeAlias(resolveBaseFontName(font));
if (aliasKey != null) {
Type3FontLibraryEntry entry = aliasIndex.get(aliasKey);
if (entry != null) {
log.debug(
"[TYPE3] Matched Type3 font {} to library entry {} via alias {}",
fontUid,
entry.getId(),
aliasKey);
return Type3FontLibraryMatch.builder()
.entry(entry)
.matchType("alias:" + aliasKey)
.signature(signature)
.build();
}
}
if (signature != null) {
log.debug(
"[TYPE3] Library had no alias match for signature {} (font {})",
signature,
fontUid != null ? fontUid : font.getName());
}
return null;
}
private Type3FontLibraryEntry toEntry(RawEntry rawEntry) {
if (rawEntry == null || rawEntry.id == null) {
return null;
}
try {
Type3FontLibraryEntry.Type3FontLibraryEntryBuilder builder =
Type3FontLibraryEntry.builder()
.id(rawEntry.id)
.label(rawEntry.label != null ? rawEntry.label : rawEntry.id)
.signatures(normalizeList(rawEntry.signatures))
.aliases(normalizeList(rawEntry.aliases))
.program(loadPayload(rawEntry.program))
.webProgram(loadPayload(rawEntry.webProgram))
.pdfProgram(loadPayload(rawEntry.pdfProgram))
.source(rawEntry.source);
if (rawEntry.glyphCoverage != null && !rawEntry.glyphCoverage.isEmpty()) {
for (Integer codePoint : rawEntry.glyphCoverage) {
if (codePoint != null) {
builder.glyphCode(codePoint);
}
}
}
return builder.build();
} catch (IOException ex) {
log.warn(
"[TYPE3] Failed to load Type3 library entry {}: {}",
rawEntry.id,
ex.getMessage());
return null;
}
}
private Type3FontLibraryPayload loadPayload(RawPayload payload) throws IOException {
if (payload == null) {
return null;
}
byte[] data = null;
if (payload.base64 != null && !payload.base64.isBlank()) {
try {
data = Base64.getDecoder().decode(payload.base64);
} catch (IllegalArgumentException ex) {
log.warn("[TYPE3] Invalid base64 payload in Type3 library: {}", ex.getMessage());
}
} else if (payload.resource != null && !payload.resource.isBlank()) {
data = loadResourceBytes(payload.resource);
}
if (data == null || data.length == 0) {
return null;
}
String base64 = Base64.getEncoder().encodeToString(data);
return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format));
}
private byte[] loadResourceBytes(String location) throws IOException {
String resolved = resolveLocation(location);
Resource resource = resourceLoader.getResource(resolved);
if (!resource.exists()) {
throw new IOException("Resource not found: " + resolved);
}
try (InputStream inputStream = resource.getInputStream()) {
return inputStream.readAllBytes();
}
}
private String resolveLocation(String location) {
if (location == null || location.isBlank()) {
return location;
}
if (location.contains(":")) {
return location;
}
if (location.startsWith("/")) {
return "classpath:" + location;
}
return "classpath:/" + location;
}
private List<String> normalizeList(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of();
}
return values.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
private String normalizeAlias(String alias) {
if (alias == null) {
return null;
}
String value = alias.trim();
int plus = value.indexOf('+');
if (plus >= 0 && plus < value.length() - 1) {
value = value.substring(plus + 1);
}
return value.isEmpty() ? null : value.toLowerCase(Locale.ROOT);
}
private String normalizeFormat(String format) {
if (format == null) {
return null;
}
return format.trim().toLowerCase(Locale.ROOT);
}
private String resolveBaseFontName(PDType3Font font) {
if (font == null) {
return null;
}
String baseName = null;
try {
baseName = font.getName();
} catch (Exception ignored) {
// Some Type3 fonts throw when resolving names; fall back to COS dictionary.
}
if (baseName == null && font.getCOSObject() != null) {
baseName = font.getCOSObject().getNameAsString(COSName.BASE_FONT);
}
return baseName;
}
private static final class RawEntry {
public String id;
public String label;
public List<String> signatures;
public List<String> aliases;
public RawPayload program;
public RawPayload webProgram;
public RawPayload pdfProgram;
public List<Integer> glyphCoverage;
public String source;
}
private static final class RawPayload {
public String resource;
public String format;
public String base64;
}
}
@@ -0,0 +1,30 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import java.util.List;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
@Value
@Builder
public class Type3FontLibraryEntry {
String id;
String label;
@Singular List<String> signatures;
@Singular List<String> aliases;
Type3FontLibraryPayload program;
Type3FontLibraryPayload webProgram;
Type3FontLibraryPayload pdfProgram;
@Singular("glyphCode")
List<Integer> glyphCoverage;
String source;
public boolean hasAnyPayload() {
return (program != null && program.hasPayload())
|| (webProgram != null && webProgram.hasPayload())
|| (pdfProgram != null && pdfProgram.hasPayload());
}
}
@@ -0,0 +1,12 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class Type3FontLibraryMatch {
Type3FontLibraryEntry entry;
String matchType;
String signature;
}
@@ -0,0 +1,13 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import lombok.Value;
@Value
public class Type3FontLibraryPayload {
String base64;
String format;
public boolean hasPayload() {
return base64 != null && !base64.isBlank();
}
}
@@ -0,0 +1,25 @@
package stirling.software.SPDF.service.pdfjson.type3.model;
import java.awt.geom.GeneralPath;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class Type3GlyphOutline {
String glyphName;
int charCode;
float advanceWidth;
PDRectangle boundingBox;
GeneralPath outline;
boolean hasStroke;
boolean hasFill;
boolean hasImages;
boolean hasText;
boolean hasShading;
String warnings;
Integer unicode;
}
@@ -0,0 +1,299 @@
package stirling.software.SPDF.service.pdfjson.type3.tool;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
/**
* Small CLI helper that scans a PDF for Type3 fonts, computes their signatures, and optionally
* emits JSON describing the glyph coverage. This allows Type3 library entries to be added without
* digging through backend logs.
*
* <p>Usage:
*
* <pre>
* ./gradlew :proprietary:type3SignatureTool --args="--pdf path/to/sample.pdf --output type3.json --pretty"
* </pre>
*/
public final class Type3SignatureTool {
private static final ObjectMapper OBJECT_MAPPER =
new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
private Type3SignatureTool() {}
public static void main(String[] args) throws Exception {
Arguments arguments = Arguments.parse(args);
if (arguments.showHelp || arguments.pdf == null) {
printUsage();
return;
}
Path pdfPath = arguments.pdf.toAbsolutePath();
if (!Files.exists(pdfPath)) {
throw new IOException("PDF not found: " + pdfPath);
}
List<Map<String, Object>> fonts;
try (PDDocument document = Loader.loadPDF(pdfPath.toFile())) {
fonts = collectType3Fonts(document);
}
Map<String, Object> output = new LinkedHashMap<>();
output.put("pdf", pdfPath.toString());
output.put("fonts", fonts);
ObjectWriter writer =
arguments.pretty
? OBJECT_MAPPER.writerWithDefaultPrettyPrinter()
: OBJECT_MAPPER.writer();
if (arguments.output != null) {
Path parent = arguments.output.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
writer.writeValue(arguments.output.toFile(), output);
verifyOutput(arguments.output, fonts.size());
} else {
writer.writeValue(System.out, output);
}
}
private static List<Map<String, Object>> collectType3Fonts(PDDocument document)
throws IOException {
if (document == null || document.getNumberOfPages() == 0) {
return List.of();
}
List<Map<String, Object>> fonts = new ArrayList<>();
Type3GlyphExtractor glyphExtractor = new Type3GlyphExtractor();
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
PDPage page = document.getPage(pageIndex);
PDResources resources = page.getResources();
if (resources == null) {
continue;
}
scanResources(document, pageIndex + 1, resources, glyphExtractor, visited, fonts);
}
return fonts;
}
private static void scanResources(
PDDocument document,
int pageNumber,
PDResources resources,
Type3GlyphExtractor glyphExtractor,
Set<Object> visited,
List<Map<String, Object>> fonts)
throws IOException {
if (resources == null) {
return;
}
for (COSName name : resources.getFontNames()) {
PDFont font = resources.getFont(name);
if (!(font instanceof PDType3Font type3Font)) {
continue;
}
Object cosObject = type3Font.getCOSObject();
if (cosObject != null && !visited.add(cosObject)) {
continue;
}
fonts.add(
describeFont(document, pageNumber, name.getName(), type3Font, glyphExtractor));
}
Deque<PDResources> embedded = new ArrayDeque<>();
for (COSName name : resources.getXObjectNames()) {
PDXObject xobject = resources.getXObject(name);
if (xobject instanceof PDFormXObject form && form.getResources() != null) {
embedded.add(form.getResources());
}
}
while (!embedded.isEmpty()) {
scanResources(document, pageNumber, embedded.pop(), glyphExtractor, visited, fonts);
}
}
private static Map<String, Object> describeFont(
PDDocument document,
int pageNumber,
String fontId,
PDType3Font font,
Type3GlyphExtractor glyphExtractor)
throws IOException {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("pageNumber", pageNumber);
payload.put("fontId", fontId);
payload.put("baseName", safeFontName(font));
payload.put("alias", normalizeAlias(safeFontName(font)));
payload.put("encoding", resolveEncoding(font));
payload.put("signature", Type3FontSignatureCalculator.computeSignature(font));
List<Type3GlyphOutline> glyphs =
glyphExtractor.extractGlyphs(document, font, fontId, pageNumber);
payload.put("glyphCount", glyphs != null ? glyphs.size() : 0);
Set<Integer> coverage = new TreeSet<>();
if (glyphs != null) {
for (Type3GlyphOutline glyph : glyphs) {
if (glyph == null) {
continue;
}
if (glyph.getUnicode() != null) {
coverage.add(glyph.getUnicode());
} else if (glyph.getCharCode() >= 0) {
coverage.add(0xF000 | (glyph.getCharCode() & 0xFF));
}
}
List<Map<String, Object>> warnings = new ArrayList<>();
for (Type3GlyphOutline glyph : glyphs) {
if (glyph != null && glyph.getWarnings() != null) {
Map<String, Object> warning = new LinkedHashMap<>();
warning.put("glyphName", glyph.getGlyphName());
warning.put("message", glyph.getWarnings());
warnings.add(warning);
}
}
if (!warnings.isEmpty()) {
payload.put("warnings", warnings);
}
}
if (!coverage.isEmpty()) {
payload.put("glyphCoverage", new ArrayList<>(coverage));
}
return payload;
}
private static void verifyOutput(Path output, int fontCount) throws IOException {
Path absolute = output.toAbsolutePath();
if (!Files.exists(absolute)) {
throw new IOException("Expected output file not found: " + absolute);
}
long size = Files.size(absolute);
if (size == 0) {
throw new IOException("Output file is empty: " + absolute);
}
System.out.println(
"Wrote " + fontCount + " fonts to " + absolute + " (" + size + " bytes, verified)");
}
private static String resolveEncoding(PDType3Font font) {
if (font == null || font.getEncoding() == null) {
return null;
}
Object encoding = font.getCOSObject().getDictionaryObject(COSName.ENCODING);
return encoding != null
? encoding.toString()
: font.getEncoding().getClass().getSimpleName();
}
private static String safeFontName(PDType3Font font) {
if (font == null) {
return null;
}
try {
if (font.getName() != null) {
return font.getName();
}
} catch (Exception ignored) {
// ignore
}
if (font.getCOSObject() != null) {
return font.getCOSObject().getNameAsString(COSName.BASE_FONT);
}
return null;
}
private static String normalizeAlias(String name) {
if (name == null) {
return null;
}
int plus = name.indexOf('+');
String normalized = plus >= 0 ? name.substring(plus + 1) : name;
normalized = normalized.trim();
return normalized.isEmpty() ? null : normalized.toLowerCase(Locale.ROOT);
}
private static void printUsage() {
System.out.println(
"""
Type3SignatureTool - dump Type3 font signatures for library building
Usage:
--pdf <file.pdf> Input PDF to analyse (required)
--output <file.json> Optional output file (defaults to stdout)
--pretty Pretty-print JSON output
--help Show this help
Example:
./gradlew :proprietary:type3SignatureTool --args="--pdf samples/foo.pdf --output foo.json --pretty"
""");
}
private static final class Arguments {
private final Path pdf;
private final Path output;
private final boolean pretty;
private final boolean showHelp;
private Arguments(Path pdf, Path output, boolean pretty, boolean showHelp) {
this.pdf = pdf;
this.output = output;
this.pretty = pretty;
this.showHelp = showHelp;
}
static Arguments parse(String[] args) {
if (args == null || args.length == 0) {
return new Arguments(null, null, true, true);
}
Path pdf = null;
Path output = null;
boolean pretty = false;
boolean showHelp = false;
for (int i = 0; i < args.length; i++) {
String arg = args[i];
if ("--pdf".equals(arg) && i + 1 < args.length) {
pdf = Paths.get(args[++i]);
} else if ("--output".equals(arg) && i + 1 < args.length) {
output = Paths.get(args[++i]);
} else if ("--pretty".equals(arg)) {
pretty = true;
} else if ("--help".equals(arg) || "-h".equals(arg)) {
showHelp = true;
}
}
return new Arguments(pdf, output, pretty, showHelp);
}
}
}