mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54:06 +02:00
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:
+225
@@ -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
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
+576
@@ -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) {}
|
||||
}
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+350
@@ -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};
|
||||
}
|
||||
}
|
||||
+474
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -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;
|
||||
}
|
||||
}
|
||||
+308
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+133
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
+126
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+299
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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());
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+13
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+299
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user