mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e7a8181a | ||
|
|
3d57510eae | ||
|
|
4b94628976 | ||
|
|
5170cd2444 | ||
|
|
59361b4231 | ||
|
|
691aaafae6 | ||
|
|
024d67b393 | ||
|
|
67ecba1519 | ||
|
|
63eceb496f | ||
|
|
e310493966 | ||
|
|
0a1d2effdc | ||
|
|
b8ce4e47c1 | ||
|
|
946196de43 | ||
|
|
27bd34c29b | ||
|
|
5a1ed50e2b | ||
|
|
e01734fb7d | ||
|
|
7c3c7937b3 | ||
|
|
b1d44d5661 | ||
|
|
71c845bcd8 | ||
|
|
c62277a8e5 | ||
|
|
f3a4dbc903 | ||
|
|
4b14ddfb37 | ||
|
|
597cc460aa | ||
|
|
f88f1db7e7 | ||
|
|
e523190f39 | ||
|
|
f9d2f36ab7 | ||
|
|
5df466266a |
@@ -30,6 +30,91 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
@@ -37,7 +122,7 @@ Frontend designed for **stateful document processing**:
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `src/contexts/FileContext.tsx`
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
@@ -62,7 +147,7 @@ Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
@@ -147,8 +232,34 @@ return useToolOperation({
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
@@ -203,6 +314,7 @@ return useToolOperation({
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
|
||||
@@ -391,13 +391,24 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Advance", "extract-image-scans");
|
||||
addEndpointToGroup("Advance", "repair");
|
||||
addEndpointToGroup("Advance", "auto-rename");
|
||||
addEndpointToGroup("Advance", "handleData");
|
||||
addEndpointToGroup("Advance", "scanner-effect");
|
||||
addEndpointToGroup("Advance", "show-javascript");
|
||||
addEndpointToGroup("Advance", "overlay-pdf");
|
||||
// Backend-only endpoints
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
|
||||
// Adding endpoints to "Automation" group
|
||||
addEndpointToGroup("Automation", "handleData");
|
||||
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
|
||||
addEndpointToGroup("Automation", "pipeline");
|
||||
|
||||
// Adding endpoints to "DeveloperTools" group
|
||||
addEndpointToGroup("DeveloperTools", "show-javascript");
|
||||
|
||||
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
|
||||
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
|
||||
@@ -29,14 +29,14 @@ import org.springframework.stereotype.Component;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
@@ -101,8 +101,8 @@ public class ApplicationProperties {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize fileUploadLimit from environment variables if not set in settings.yml.
|
||||
* Supports SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
|
||||
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
|
||||
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
|
||||
*/
|
||||
@PostConstruct
|
||||
public void initializeFileUploadLimitFromEnv() {
|
||||
@@ -124,12 +124,18 @@ public class ApplicationProperties {
|
||||
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
|
||||
if (sizeInMB > 0 && sizeInMB <= 999) {
|
||||
fileUploadLimit = sizeInMB + "MB";
|
||||
log.info("Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB", sizeInMB);
|
||||
log.info(
|
||||
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
|
||||
sizeInMB);
|
||||
} else {
|
||||
log.warn("SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring", sizeInMB);
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
|
||||
sizeInMB);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring", systemMaxFileSize);
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring",
|
||||
systemMaxFileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +398,9 @@ public class ApplicationProperties {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
private int keyRetentionDays = 7;
|
||||
private int keyRetentionDays = 30;
|
||||
private int tokenExpiryMinutes = 1440;
|
||||
private int allowedClockSkewSeconds = 60;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -610,6 +618,8 @@ public class ApplicationProperties {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Form field information with coordinates for interactive form viewer. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Form field with coordinates and metadata")
|
||||
public class FormFieldWithCoordinates {
|
||||
|
||||
@Schema(description = "Fully qualified field name", example = "form1.firstName")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "Display label for the field", example = "First Name")
|
||||
private String label;
|
||||
|
||||
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "Current field value")
|
||||
private String value;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Available options (export values) for choice fields"
|
||||
+ " (dropdown, radio, listbox)")
|
||||
private List<String> options;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Human-readable display labels for choice field options,"
|
||||
+ " parallel to the 'options' list. Null when identical to options.")
|
||||
private List<String> displayOptions;
|
||||
|
||||
@Schema(description = "Whether the field is required")
|
||||
private boolean required;
|
||||
|
||||
@Schema(description = "Whether the field is read-only")
|
||||
private boolean readOnly;
|
||||
|
||||
@Schema(description = "Whether this is a multi-select list box")
|
||||
private boolean multiSelect;
|
||||
|
||||
@Schema(description = "Whether this is a multi-line text field")
|
||||
private boolean multiline;
|
||||
|
||||
@Schema(description = "Tooltip/alternate name for the field")
|
||||
private String tooltip;
|
||||
|
||||
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
|
||||
private List<WidgetCoordinates> widgets;
|
||||
|
||||
/**
|
||||
* Coordinates for a single widget annotation (visual representation of the field). A field can
|
||||
* have multiple widgets if it appears on multiple pages.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Widget coordinates in PDF space")
|
||||
public static class WidgetCoordinates {
|
||||
|
||||
@Schema(description = "Page index (0-based)", example = "0")
|
||||
private int pageIndex;
|
||||
|
||||
@Schema(description = "X coordinate in PDF points (lower-left origin)")
|
||||
private float x;
|
||||
|
||||
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
|
||||
private float y;
|
||||
|
||||
@Schema(description = "Width in PDF points")
|
||||
private float width;
|
||||
|
||||
@Schema(description = "Height in PDF points")
|
||||
private float height;
|
||||
|
||||
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
|
||||
private String exportValue;
|
||||
|
||||
@Schema(description = "Font size in PDF points")
|
||||
private Float fontSize;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
/**
|
||||
* Interface for checking license status dynamically. Implementation provided by proprietary module
|
||||
* when available.
|
||||
*/
|
||||
public interface LicenseServiceInterface {
|
||||
|
||||
/**
|
||||
* Get the license type as a string.
|
||||
*
|
||||
* @return "NORMAL", "SERVER", or "ENTERPRISE"
|
||||
*/
|
||||
String getLicenseTypeName();
|
||||
|
||||
/**
|
||||
* Check if running Pro or higher (SERVER or ENTERPRISE license).
|
||||
*
|
||||
* @return true if SERVER or ENTERPRISE license is active
|
||||
*/
|
||||
boolean isRunningProOrHigher();
|
||||
|
||||
/**
|
||||
* Check if running Enterprise edition.
|
||||
*
|
||||
* @return true if ENTERPRISE license is active
|
||||
*/
|
||||
boolean isRunningEE();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,6 +26,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
|
||||
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Path tempDirectory;
|
||||
|
||||
@@ -121,7 +125,8 @@ public class MobileScannerService {
|
||||
// Handle duplicate filenames
|
||||
int counter = 1;
|
||||
while (Files.exists(filePath)) {
|
||||
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
|
||||
String nameWithoutExt =
|
||||
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
@@ -271,14 +276,14 @@ public class MobileScannerService {
|
||||
throw new IllegalArgumentException("Session ID cannot be empty");
|
||||
}
|
||||
// Basic validation: alphanumeric and hyphens only
|
||||
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
|
||||
if (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
|
||||
throw new IllegalArgumentException("Invalid session ID format");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
|
||||
// Ensure we have a non-empty, safe filename
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = "upload-" + System.currentTimeMillis();
|
||||
|
||||
@@ -107,56 +107,65 @@ public class PDFToFile {
|
||||
File[] outputFiles =
|
||||
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
|
||||
List<File> markdownFiles = new ArrayList<>();
|
||||
List<File> imageFiles = new ArrayList<>();
|
||||
|
||||
// Convert HTML files to Markdown
|
||||
// Convert HTML files to Markdown and collect image files
|
||||
for (File outputFile : outputFiles) {
|
||||
if (outputFile.getName().endsWith(".html")) {
|
||||
String html = Files.readString(outputFile.toPath());
|
||||
String markdown = htmlToMarkdownConverter.convert(html);
|
||||
|
||||
// Update image references to point to images/ folder
|
||||
markdown = updateImageReferences(markdown);
|
||||
|
||||
String mdFileName = outputFile.getName().replace(".html", ".md");
|
||||
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
|
||||
Files.writeString(mdFile.toPath(), markdown);
|
||||
markdownFiles.add(mdFile);
|
||||
} else if (!outputFile.getName().endsWith(".md")) {
|
||||
// Collect non-HTML, non-MD files as images/assets
|
||||
imageFiles.add(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's only one markdown file, return it directly
|
||||
if (markdownFiles.size() == 1) {
|
||||
fileName = pdfBaseName + ".md";
|
||||
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
|
||||
} else {
|
||||
// Multiple files - create a zip
|
||||
fileName = pdfBaseName + "ToMarkdown.zip";
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
// Always create a ZIP file
|
||||
fileName = pdfBaseName + "ToMarkdown.zip";
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
|
||||
// Add markdown files
|
||||
for (File mdFile : markdownFiles) {
|
||||
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
|
||||
zipOutputStream.putNextEntry(mdEntry);
|
||||
Files.copy(mdFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
// Add images and other assets
|
||||
for (File file : outputFiles) {
|
||||
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
|
||||
ZipEntry assetEntry = new ZipEntry(file.getName());
|
||||
zipOutputStream.putNextEntry(assetEntry);
|
||||
Files.copy(file.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
|
||||
// Add markdown files to root of ZIP
|
||||
for (File mdFile : markdownFiles) {
|
||||
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
|
||||
zipOutputStream.putNextEntry(mdEntry);
|
||||
Files.copy(mdFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
// Add images and other assets to images/ folder
|
||||
for (File imageFile : imageFiles) {
|
||||
ZipEntry assetEntry = new ZipEntry("images/" + imageFile.getName());
|
||||
zipOutputStream.putNextEntry(assetEntry);
|
||||
Files.copy(imageFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates image references in markdown to point to the images/ folder. Matches patterns like
|
||||
*  and converts to 
|
||||
*/
|
||||
private String updateImageReferences(String markdown) {
|
||||
// Match markdown image syntax: 
|
||||
// Only update if the path doesn't already start with images/
|
||||
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
|
||||
throws IOException, InterruptedException {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
|
||||
@@ -574,34 +574,39 @@ public class PdfUtils {
|
||||
boolean everyPage)
|
||||
throws IOException {
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfBytes);
|
||||
|
||||
// Get the first page of the PDF
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
// Create an image object from the image bytes
|
||||
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
// Draw the image onto the page at the specified x and y coordinates
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF");
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
|
||||
// Get the first page of the PDF
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
// Create an image object from the image bytes
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
// Draw the image onto the page at the specified x and y coordinates
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF");
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Log an error message if there is an issue overlaying the image onto the PDF
|
||||
log.error("Error overlaying image onto PDF", e);
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Log an error message if there is an issue overlaying the image onto the PDF
|
||||
log.error("Error overlaying image onto PDF", e);
|
||||
throw e;
|
||||
}
|
||||
// Create a ByteArrayOutputStream to save the PDF to
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
log.info("PDF successfully saved to byte array");
|
||||
return baos.toByteArray();
|
||||
}
|
||||
// Create a ByteArrayOutputStream to save the PDF to
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
log.info("PDF successfully saved to byte array");
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
|
||||
|
||||
@@ -47,6 +47,7 @@ public class SvgSanitizer {
|
||||
private static final Pattern DATA_SCRIPT_PATTERN =
|
||||
Pattern.compile(
|
||||
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern NULL_BYTE_PATTERN = Pattern.compile("\u0000");
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -210,7 +211,7 @@ public class SvgSanitizer {
|
||||
|
||||
String result = url.trim();
|
||||
|
||||
result = result.replaceAll("\u0000", "");
|
||||
result = NULL_BYTE_PATTERN.matcher(result).replaceAll("");
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
|
||||
@@ -153,11 +153,12 @@ class PDFToFileTest {
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"Fake PDF content".getBytes());
|
||||
|
||||
// Create a mock HTML output file
|
||||
// Create a mock HTML output file with image references
|
||||
Path htmlOutputFile = tempDir.resolve("test.html");
|
||||
Files.write(
|
||||
htmlOutputFile,
|
||||
"<html><body><h1>Test</h1><p>This is a test.</p></body></html>".getBytes());
|
||||
"<html><body><h1>Test</h1><p>This is a test.</p><img src=\"image1.png\" /></body></html>"
|
||||
.getBytes());
|
||||
|
||||
// Setup ProcessExecutor mock
|
||||
mockedStaticProcessExecutor
|
||||
@@ -174,18 +175,61 @@ class PDFToFileTest {
|
||||
Files.copy(
|
||||
htmlOutputFile, Path.of(outputDir.getPath(), "test.html"));
|
||||
|
||||
// Create a mock image file
|
||||
Files.write(
|
||||
Path.of(outputDir.getPath(), "image1.png"),
|
||||
"Fake image data".getBytes());
|
||||
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
// Execute the method
|
||||
ResponseEntity<byte[]> response = pdfToFile.processPdfToMarkdown(pdfFile);
|
||||
|
||||
// Verify
|
||||
// Verify - should now return a ZIP file instead of plain markdown
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().length > 0);
|
||||
|
||||
// Verify content disposition indicates a ZIP file
|
||||
assertTrue(
|
||||
response.getHeaders().getContentDisposition().toString().contains("test.md"));
|
||||
response.getHeaders()
|
||||
.getContentDisposition()
|
||||
.toString()
|
||||
.contains("ToMarkdown.zip"));
|
||||
|
||||
// Verify the content by unzipping it
|
||||
try (ZipInputStream zipStream =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new java.io.ByteArrayInputStream(response.getBody()))) {
|
||||
ZipEntry entry;
|
||||
boolean foundMdFile = false;
|
||||
boolean foundImageInFolder = false;
|
||||
String markdownContent = null;
|
||||
|
||||
while ((entry = zipStream.getNextEntry()) != null) {
|
||||
if (entry.getName().endsWith(".md")) {
|
||||
foundMdFile = true;
|
||||
// Read markdown content to verify image references
|
||||
markdownContent =
|
||||
new String(
|
||||
zipStream.readAllBytes(),
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
} else if (entry.getName().startsWith("images/")
|
||||
&& entry.getName().endsWith(".png")) {
|
||||
foundImageInFolder = true;
|
||||
}
|
||||
zipStream.closeEntry();
|
||||
}
|
||||
|
||||
assertTrue(foundMdFile, "ZIP should contain Markdown file");
|
||||
assertTrue(foundImageInFolder, "ZIP should contain image in images/ folder");
|
||||
assertNotNull(markdownContent, "Markdown content should be present");
|
||||
// Verify markdown references images with images/ prefix
|
||||
assertTrue(
|
||||
markdownContent.contains("images/"),
|
||||
"Markdown should reference images with images/ prefix");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,14 +300,15 @@ class PDFToFileTest {
|
||||
while ((entry = zipStream.getNextEntry()) != null) {
|
||||
if (entry.getName().endsWith(".md")) {
|
||||
foundMdFiles = true;
|
||||
} else if (entry.getName().endsWith(".png")) {
|
||||
} else if (entry.getName().startsWith("images/")
|
||||
&& entry.getName().endsWith(".png")) {
|
||||
foundImage = true;
|
||||
}
|
||||
zipStream.closeEntry();
|
||||
}
|
||||
|
||||
assertTrue(foundMdFiles, "ZIP should contain Markdown files");
|
||||
assertTrue(foundImage, "ZIP should contain image files");
|
||||
assertTrue(foundImage, "ZIP should contain image files in images/ folder");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ def generatedFrontendPaths = [
|
||||
]
|
||||
|
||||
tasks.register('npmInstall', Exec) {
|
||||
doNotTrackState("node_modules contains symlinks that Gradle cannot snapshot on Windows/WSL")
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Install frontend dependencies'
|
||||
@@ -214,6 +215,7 @@ tasks.register('npmInstall', Exec) {
|
||||
}
|
||||
|
||||
tasks.register('npmBuild', Exec) {
|
||||
doNotTrackState("Frontend build depends on untracked npmInstall task")
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Build frontend application'
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -38,6 +39,10 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
})
|
||||
public class SPDFApplication {
|
||||
|
||||
private static final Pattern PORT_SUFFIX_PATTERN = Pattern.compile(".+:\\d+$");
|
||||
private static final Pattern URL_SCHEME_PATTERN =
|
||||
Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/+$");
|
||||
private static String serverPortStatic;
|
||||
private static String baseUrlStatic;
|
||||
private static String contextPathStatic;
|
||||
@@ -244,8 +249,8 @@ public class SPDFApplication {
|
||||
String trimmedBase =
|
||||
(backendUrl == null || backendUrl.isBlank())
|
||||
? "http://localhost"
|
||||
: backendUrl.trim().replaceAll("/+$", "");
|
||||
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
: TRAILING_SLASH_PATTERN.matcher(backendUrl.trim()).replaceAll("");
|
||||
boolean hasScheme = URL_SCHEME_PATTERN.matcher(trimmedBase).matches();
|
||||
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
|
||||
Integer parsedPort = parsePort(port);
|
||||
|
||||
@@ -298,7 +303,7 @@ public class SPDFApplication {
|
||||
if (port == null) {
|
||||
return trimmedBase;
|
||||
}
|
||||
if (trimmedBase.matches(".+:\\d+$")) {
|
||||
if (PORT_SUFFIX_PATTERN.matcher(trimmedBase).matches()) {
|
||||
return trimmedBase;
|
||||
}
|
||||
return trimmedBase + ":" + port;
|
||||
|
||||
@@ -14,9 +14,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.SPDF.controller.web.UploadLimitService;
|
||||
|
||||
/**
|
||||
* Configuration for Spring multipart file upload settings.
|
||||
* Synchronizes multipart limits with fileUploadLimit from settings.yml or environment variables
|
||||
* (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE).
|
||||
* Configuration for Spring multipart file upload settings. Synchronizes multipart limits with
|
||||
* fileUploadLimit from settings.yml or environment variables (SYSTEMFILEUPLOADLIMIT or
|
||||
* SYSTEM_MAXFILESIZE).
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
@@ -25,9 +25,9 @@ public class MultipartConfiguration {
|
||||
@Autowired private UploadLimitService uploadLimitService;
|
||||
|
||||
/**
|
||||
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml
|
||||
* or environment variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE).
|
||||
* Depends on ApplicationProperties being initialized so @PostConstruct has run.
|
||||
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment
|
||||
* variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE). Depends on ApplicationProperties
|
||||
* being initialized so @PostConstruct has run.
|
||||
*/
|
||||
@Bean
|
||||
@DependsOn("applicationProperties")
|
||||
@@ -35,7 +35,8 @@ public class MultipartConfiguration {
|
||||
MultipartConfigFactory factory = new MultipartConfigFactory();
|
||||
|
||||
// First check if SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE is explicitly set
|
||||
String springMaxFileSize = java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
|
||||
String springMaxFileSize =
|
||||
java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
|
||||
long uploadLimitBytes = 0;
|
||||
|
||||
if (springMaxFileSize != null && !springMaxFileSize.trim().isEmpty()) {
|
||||
@@ -45,7 +46,10 @@ public class MultipartConfiguration {
|
||||
uploadLimitBytes = dataSize.toBytes();
|
||||
log.info("Using SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize, e);
|
||||
log.warn(
|
||||
"Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}",
|
||||
springMaxFileSize,
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,4 +77,3 @@ public class MultipartConfiguration {
|
||||
return factory.createMultipartConfig();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -51,6 +52,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class MergeController {
|
||||
|
||||
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -173,7 +175,7 @@ public class MergeController {
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
|
||||
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+56
-54
@@ -101,66 +101,68 @@ public class SplitPdfBySectionsController {
|
||||
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
|
||||
}
|
||||
} else {
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addSingleSectionToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
subDoc,
|
||||
subLayerUtility,
|
||||
i,
|
||||
j,
|
||||
horiz,
|
||||
verti);
|
||||
int sectionNum = i * verti + j + 1;
|
||||
String entryName =
|
||||
filename
|
||||
+ "_"
|
||||
+ pageNum
|
||||
+ "_"
|
||||
+ sectionNum
|
||||
+ ".pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error creating section {} for page {}",
|
||||
(i * verti + j + 1),
|
||||
pageNum,
|
||||
e);
|
||||
throw e;
|
||||
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addSingleSectionToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
subDoc,
|
||||
subLayerUtility,
|
||||
i,
|
||||
j,
|
||||
horiz,
|
||||
verti);
|
||||
int sectionNum = i * verti + j + 1;
|
||||
String entryName =
|
||||
filename
|
||||
+ "_"
|
||||
+ pageNum
|
||||
+ "_"
|
||||
+ sectionNum
|
||||
+ ".pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error creating section {} for page {}",
|
||||
(i * verti + j + 1),
|
||||
pageNum,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
|
||||
String entryName = filename + "_" + pageNum + "_1.pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error("Error processing unsplit page {}", pageNum, e);
|
||||
throw e;
|
||||
} else {
|
||||
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addPageToTarget(
|
||||
sourceDocument, pageIndex, subDoc, subLayerUtility);
|
||||
String entryName = filename + "_" + pageNum + "_1.pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error("Error processing unsplit page {}", pageNum, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating ZIP file with split PDF sections", e);
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating ZIP file with split PDF sections", e);
|
||||
throw e;
|
||||
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
|
||||
|
||||
+164
-23
@@ -44,7 +44,7 @@ public class ConvertMarkdownToPdf {
|
||||
@Operation(
|
||||
summary = "Convert a Markdown file to PDF",
|
||||
description =
|
||||
"This endpoint takes a Markdown file input, converts it to HTML, and then to"
|
||||
"This endpoint takes a Markdown file or ZIP (containing Markdown + images) input, converts it to HTML, and then to"
|
||||
+ " PDF format. Input:MARKDOWN Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> markdownToPdf(@ModelAttribute GeneralFile generalFile)
|
||||
throws Exception {
|
||||
@@ -52,40 +52,181 @@ public class ConvertMarkdownToPdf {
|
||||
|
||||
if (fileInput == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", "Markdown");
|
||||
"error.fileFormatRequired", "File must be in {0} format", "Markdown or ZIP");
|
||||
}
|
||||
|
||||
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
|
||||
if (originalFilename == null || !originalFilename.endsWith(".md")) {
|
||||
if (originalFilename == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", ".md");
|
||||
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
|
||||
}
|
||||
|
||||
// Convert Markdown to HTML using CommonMark
|
||||
List<Extension> extensions = List.of(TablesExtension.create());
|
||||
Parser parser = Parser.builder().extensions(extensions).build();
|
||||
boolean isZip = originalFilename.toLowerCase().endsWith(".zip");
|
||||
boolean isMarkdown = originalFilename.toLowerCase().endsWith(".md");
|
||||
|
||||
Node document = parser.parse(new String(fileInput.getBytes()));
|
||||
HtmlRenderer renderer =
|
||||
HtmlRenderer.builder()
|
||||
.attributeProviderFactory(context -> new TableAttributeProvider())
|
||||
.extensions(extensions)
|
||||
.build();
|
||||
if (!isZip && !isMarkdown) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
|
||||
}
|
||||
|
||||
String htmlContent = renderer.render(document);
|
||||
byte[] pdfBytes;
|
||||
String outputFilename;
|
||||
|
||||
if (isZip) {
|
||||
// Handle ZIP file containing markdown + images
|
||||
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
|
||||
// Extract ZIP to temp directory
|
||||
java.nio.file.Path tempDirPath = tempDir.getPath();
|
||||
try (java.util.zip.ZipInputStream zipIn =
|
||||
io.github.pixee.security.ZipSecurity.createHardenedInputStream(
|
||||
new java.io.ByteArrayInputStream(fileInput.getBytes()))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
java.nio.file.Path filePath = tempDirPath.resolve(entry.getName());
|
||||
java.nio.file.Files.createDirectories(filePath.getParent());
|
||||
java.nio.file.Files.copy(zipIn, filePath);
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
// Find the markdown file (look for .md files, prefer index.md or first one)
|
||||
java.io.File markdownFile = findMarkdownFile(tempDirPath.toFile());
|
||||
if (markdownFile == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired",
|
||||
"ZIP must contain at least one {0} file",
|
||||
".md");
|
||||
}
|
||||
|
||||
// Read and convert markdown to HTML
|
||||
String markdownContent = java.nio.file.Files.readString(markdownFile.toPath());
|
||||
List<Extension> extensions = List.of(TablesExtension.create());
|
||||
Parser parser = Parser.builder().extensions(extensions).build();
|
||||
Node document = parser.parse(markdownContent);
|
||||
HtmlRenderer renderer =
|
||||
HtmlRenderer.builder()
|
||||
.attributeProviderFactory(context -> new TableAttributeProvider())
|
||||
.extensions(extensions)
|
||||
.build();
|
||||
String htmlContent = renderer.render(document);
|
||||
|
||||
// Create a new ZIP with HTML + images for WeasyPrint
|
||||
byte[] htmlZipBytes = createHtmlZip(htmlContent, tempDirPath.toFile());
|
||||
|
||||
// Use FileToPdf which already supports ZIP files with images
|
||||
pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
htmlZipBytes,
|
||||
"package.zip",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
|
||||
outputFilename =
|
||||
GeneralUtils.generateFilename(
|
||||
originalFilename.substring(0, originalFilename.lastIndexOf('.')),
|
||||
".pdf");
|
||||
}
|
||||
} else {
|
||||
// Handle plain markdown file (no images)
|
||||
List<Extension> extensions = List.of(TablesExtension.create());
|
||||
Parser parser = Parser.builder().extensions(extensions).build();
|
||||
|
||||
Node document = parser.parse(new String(fileInput.getBytes()));
|
||||
HtmlRenderer renderer =
|
||||
HtmlRenderer.builder()
|
||||
.attributeProviderFactory(context -> new TableAttributeProvider())
|
||||
.extensions(extensions)
|
||||
.build();
|
||||
|
||||
String htmlContent = renderer.render(document);
|
||||
|
||||
pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
htmlContent.getBytes(),
|
||||
"converted.html",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
|
||||
outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
|
||||
}
|
||||
|
||||
byte[] pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
htmlContent.getBytes(),
|
||||
"converted.html",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
String outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a markdown file in the directory. Prefers index.md, otherwise returns the first .md
|
||||
* file found.
|
||||
*/
|
||||
private java.io.File findMarkdownFile(java.io.File directory) throws java.io.IOException {
|
||||
java.io.File indexMd = new java.io.File(directory, "index.md");
|
||||
if (indexMd.exists()) {
|
||||
return indexMd;
|
||||
}
|
||||
|
||||
// Search for any .md file
|
||||
try (java.util.stream.Stream<java.nio.file.Path> paths =
|
||||
java.nio.file.Files.walk(directory.toPath())) {
|
||||
return paths.filter(p -> p.toString().toLowerCase().endsWith(".md"))
|
||||
.findFirst()
|
||||
.map(java.nio.file.Path::toFile)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ZIP file containing the HTML content and all other files (images) from the
|
||||
* directory.
|
||||
*/
|
||||
private byte[] createHtmlZip(String htmlContent, java.io.File sourceDir)
|
||||
throws java.io.IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
|
||||
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
|
||||
// Add HTML file to root
|
||||
java.util.zip.ZipEntry htmlEntry = new java.util.zip.ZipEntry("index.html");
|
||||
zos.putNextEntry(htmlEntry);
|
||||
zos.write(htmlContent.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
|
||||
// Add all other files (images, etc.)
|
||||
addDirectoryToZip(zos, sourceDir.toPath(), sourceDir.toPath());
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Recursively adds files from a directory to a ZIP, excluding .md files. */
|
||||
private void addDirectoryToZip(
|
||||
java.util.zip.ZipOutputStream zos,
|
||||
java.nio.file.Path sourceDir,
|
||||
java.nio.file.Path rootDir)
|
||||
throws java.io.IOException {
|
||||
try (java.util.stream.Stream<java.nio.file.Path> paths =
|
||||
java.nio.file.Files.walk(sourceDir, 1)) {
|
||||
for (java.nio.file.Path path : paths.toList()) {
|
||||
if (java.nio.file.Files.isDirectory(path)) {
|
||||
if (!path.equals(sourceDir)) {
|
||||
addDirectoryToZip(zos, path, rootDir);
|
||||
}
|
||||
} else if (!path.toString().toLowerCase().endsWith(".md")) {
|
||||
// Add file to ZIP, maintaining relative path structure
|
||||
java.nio.file.Path relativePath = rootDir.relativize(path);
|
||||
java.util.zip.ZipEntry entry =
|
||||
new java.util.zip.ZipEntry(relativePath.toString());
|
||||
zos.putNextEntry(entry);
|
||||
java.nio.file.Files.copy(path, zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TableAttributeProvider implements AttributeProvider {
|
||||
|
||||
+14
-4
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -36,6 +37,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
|
||||
@Autowired(required = false)
|
||||
@@ -59,7 +61,9 @@ public class ConvertPdfJsonController {
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
@@ -82,7 +86,9 @@ public class ConvertPdfJsonController {
|
||||
String originalName = jsonFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
|
||||
@@ -115,7 +121,9 @@ public class ConvertPdfJsonController {
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName + "_metadata.json";
|
||||
|
||||
@@ -152,7 +160,9 @@ public class ConvertPdfJsonController {
|
||||
|
||||
String baseName =
|
||||
(filename != null && !filename.isBlank())
|
||||
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(filename))
|
||||
.replaceFirst("")
|
||||
: Optional.ofNullable(document.getMetadata())
|
||||
.map(PdfJsonMetadata::getTitle)
|
||||
.filter(title -> title != null && !title.isBlank())
|
||||
|
||||
+73
-9
@@ -35,6 +35,7 @@ public class ConfigController {
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final UserServiceInterface userService;
|
||||
private final stirling.software.common.service.LicenseServiceInterface licenseService;
|
||||
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
|
||||
|
||||
public ConfigController(
|
||||
@@ -45,15 +46,66 @@ public class ConfigController {
|
||||
ServerCertificateServiceInterface serverCertificateService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
UserServiceInterface userService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
stirling.software.common.service.LicenseServiceInterface licenseService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.userService = userService;
|
||||
this.licenseService = licenseService;
|
||||
this.externalAppDepConfig = externalAppDepConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current license type dynamically instead of from cached bean. This ensures the frontend
|
||||
* sees updated license status after admin changes the license key.
|
||||
*/
|
||||
private String getCurrentLicenseType() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
if (licenseService != null) {
|
||||
return licenseService.getLicenseTypeName();
|
||||
}
|
||||
|
||||
// Fallback to cached bean if service not available
|
||||
if (applicationContext.containsBean("license")) {
|
||||
return applicationContext.getBean("license", String.class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if running Pro or higher (SERVER or ENTERPRISE license) dynamically. */
|
||||
private Boolean isRunningProOrHigher() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
if (licenseService != null) {
|
||||
return licenseService.isRunningProOrHigher();
|
||||
}
|
||||
|
||||
// Fallback to cached bean
|
||||
if (applicationContext.containsBean("runningProOrHigher")) {
|
||||
return applicationContext.getBean("runningProOrHigher", Boolean.class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if running Enterprise edition dynamically. */
|
||||
private Boolean isRunningEE() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
if (licenseService != null) {
|
||||
return licenseService.isRunningEE();
|
||||
}
|
||||
|
||||
// Fallback to cached bean
|
||||
if (applicationContext.containsBean("runningEE")) {
|
||||
return applicationContext.getBean("runningEE", Boolean.class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
Map<String, Object> configData = new HashMap<>();
|
||||
@@ -101,6 +153,14 @@ public class ConfigController {
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
// User preference defaults
|
||||
configData.put(
|
||||
"defaultHideUnavailableTools",
|
||||
applicationProperties.getUi().isDefaultHideUnavailableTools());
|
||||
configData.put(
|
||||
"defaultHideUnavailableConversions",
|
||||
applicationProperties.getUi().isDefaultHideUnavailableConversions());
|
||||
|
||||
// Security settings
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
// If userService is null, proprietary module isn't loaded
|
||||
@@ -185,19 +245,23 @@ public class ConfigController {
|
||||
applicationProperties.getLegal().getAccessibilityStatement());
|
||||
|
||||
// Try to get EEAppConfig values if available
|
||||
// Get these dynamically to reflect current license status (not cached at startup)
|
||||
try {
|
||||
if (applicationContext.containsBean("runningProOrHigher")) {
|
||||
configData.put(
|
||||
"runningProOrHigher",
|
||||
applicationContext.getBean("runningProOrHigher", Boolean.class));
|
||||
Boolean runningProOrHigher = isRunningProOrHigher();
|
||||
if (runningProOrHigher != null) {
|
||||
configData.put("runningProOrHigher", runningProOrHigher);
|
||||
}
|
||||
if (applicationContext.containsBean("runningEE")) {
|
||||
configData.put(
|
||||
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
|
||||
|
||||
Boolean runningEE = isRunningEE();
|
||||
if (runningEE != null) {
|
||||
configData.put("runningEE", runningEE);
|
||||
}
|
||||
if (applicationContext.containsBean("license")) {
|
||||
configData.put("license", applicationContext.getBean("license", String.class));
|
||||
|
||||
String licenseType = getCurrentLicenseType();
|
||||
if (licenseType != null) {
|
||||
configData.put("license", licenseType);
|
||||
}
|
||||
|
||||
if (applicationContext.containsBean("SSOAutoLogin")) {
|
||||
configData.put(
|
||||
"SSOAutoLogin",
|
||||
|
||||
+32
-32
@@ -55,45 +55,45 @@ public class OverlayImageController {
|
||||
|
||||
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfBytes);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
if (isSvg) {
|
||||
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
|
||||
} else {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF page {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSvg) {
|
||||
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
|
||||
} else {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF page {}", i);
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
log.info("PDF with overlaid image successfully created");
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_overlayed.pdf"));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
log.info("PDF with overlaid image successfully created");
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_overlayed.pdf"));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to add image to PDF", e);
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
|
||||
+2
-1
@@ -57,6 +57,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class StampController {
|
||||
|
||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("\\r?\\n");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -266,7 +267,7 @@ public class StampController {
|
||||
.getEscapedNewlinePattern()
|
||||
.matcher(processedStampText)
|
||||
.replaceAll("\n");
|
||||
String[] lines = normalizedText.split("\\r?\\n");
|
||||
String[] lines = NEWLINE_PATTERN.split(normalizedText);
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
|
||||
+8
-4
@@ -21,6 +21,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -44,6 +45,7 @@ import stirling.software.common.util.FileMonitor;
|
||||
public class PipelineDirectoryProcessor {
|
||||
|
||||
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
|
||||
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiDocService apiDocService;
|
||||
@@ -433,10 +435,12 @@ public class PipelineDirectoryProcessor {
|
||||
|
||||
private Path determineOutputPath(PipelineConfig config, Path dir) {
|
||||
String outputDir =
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString())
|
||||
.replaceAll("\\\\?watchedFolders", "");
|
||||
WATCHED_FOLDERS_PATTERN
|
||||
.matcher(
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString()))
|
||||
.replaceAll("");
|
||||
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
@@ -28,6 +29,8 @@ public class ReactRoutingController {
|
||||
|
||||
private static final org.slf4j.Logger log =
|
||||
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
|
||||
private static final Pattern BASE_HREF_PATTERN =
|
||||
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
|
||||
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
@@ -94,9 +97,9 @@ public class ReactRoutingController {
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
BASE_HREF_PATTERN
|
||||
.matcher(html)
|
||||
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
|
||||
+16
-6
@@ -38,6 +38,7 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
@@ -133,6 +134,9 @@ import stirling.software.common.util.TempFileManager;
|
||||
@RequiredArgsConstructor
|
||||
public class PdfJsonConversionService {
|
||||
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern WHITESPACE_DASH_UNDERSCORE_PATTERN = Pattern.compile("[\\s\\-_]");
|
||||
private static final Pattern FONT_SUBSET_PREFIX_PATTERN = Pattern.compile("^[A-Z]{6}\\+");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
@@ -531,7 +535,10 @@ public class PdfJsonConversionService {
|
||||
: "Unknown";
|
||||
// Clean up subset prefix (e.g., "ABCDEF+TimesNewRoman"
|
||||
// -> "TimesNewRoman")
|
||||
String cleanName = name.replaceAll("^[A-Z]{6}\\+", "");
|
||||
String cleanName =
|
||||
FONT_SUBSET_PREFIX_PATTERN
|
||||
.matcher(name)
|
||||
.replaceAll("");
|
||||
return String.format("%s (%s)", cleanName, subtype);
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
@@ -1442,9 +1449,9 @@ public class PdfJsonConversionService {
|
||||
if (!fallbackFontService.canEncodeFully(font, text)) {
|
||||
String fontName =
|
||||
fontModel != null && fontModel.getBaseName() != null
|
||||
? fontModel
|
||||
.getBaseName()
|
||||
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
|
||||
? FONT_SUBSET_PREFIX_PATTERN
|
||||
.matcher(fontModel.getBaseName())
|
||||
.replaceAll("") // Remove subset prefix
|
||||
: (font != null ? font.getName() : "unknown");
|
||||
String fontKey = fontName + ":" + element.getFontId() + ":" + pageNumber;
|
||||
if (!warnedFonts.contains(fontKey)) {
|
||||
@@ -1902,7 +1909,10 @@ public class PdfJsonConversionService {
|
||||
if (plusIndex >= 0 && plusIndex < normalized.length() - 1) {
|
||||
normalized = normalized.substring(plusIndex + 1);
|
||||
}
|
||||
normalized = normalized.toLowerCase(Locale.ROOT).replaceAll("[\\s\\-_]", "");
|
||||
normalized =
|
||||
WHITESPACE_DASH_UNDERSCORE_PATTERN
|
||||
.matcher(normalized.toLowerCase(Locale.ROOT))
|
||||
.replaceAll("");
|
||||
|
||||
// Exact match after normalization
|
||||
try {
|
||||
@@ -3256,7 +3266,7 @@ public class PdfJsonConversionService {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = value.replaceAll("\s+", " ").trim();
|
||||
String trimmed = WHITESPACE_PATTERN.matcher(value).replaceAll(" ").trim();
|
||||
if (trimmed.length() <= 32) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
+17
-9
@@ -7,6 +7,7 @@ import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
@@ -310,6 +311,11 @@ public class PdfJsonFallbackFontService {
|
||||
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
|
||||
"DejaVuSansMono-BoldOblique",
|
||||
"ttf")));
|
||||
private static final Pattern BOLD_FONT_WEIGHT_PATTERN =
|
||||
Pattern.compile(".*[_-]?[6-9]00(wght)?.*");
|
||||
private static final Pattern FONT_NAME_DELIMITER_PATTERN = Pattern.compile("[-_,+]");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern PATTERN = Pattern.compile("^[A-Z]{6}\\+");
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
@@ -418,16 +424,18 @@ public class PdfJsonFallbackFontService {
|
||||
// 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" ->
|
||||
WHITESPACE_PATTERN
|
||||
.matcher(
|
||||
PATTERN.matcher(originalFontName)
|
||||
.replaceAll("") // Remove subset prefix
|
||||
.toLowerCase())
|
||||
.replaceAll(""); // 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 baseName = FONT_NAME_DELIMITER_PATTERN.split(normalized)[0];
|
||||
|
||||
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
|
||||
if (aliasedFontId != null) {
|
||||
@@ -470,7 +478,7 @@ public class PdfJsonFallbackFontService {
|
||||
|
||||
// Check for numeric weight indicators (600-900 = bold)
|
||||
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
|
||||
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
|
||||
if (BOLD_FONT_WEIGHT_PATTERN.matcher(normalizedFontName).matches()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -514,7 +522,7 @@ public class PdfJsonFallbackFontService {
|
||||
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
|
||||
boolean isSupported =
|
||||
baseFontId.startsWith("fallback-liberation-")
|
||||
|| baseFontId.equals("fallback-noto-sans")
|
||||
|| "fallback-noto-sans".equals(baseFontId)
|
||||
|| baseFontId.startsWith("fallback-dejavu-");
|
||||
|
||||
if (!isSupported) {
|
||||
@@ -523,8 +531,8 @@ public class PdfJsonFallbackFontService {
|
||||
|
||||
// DejaVu Sans and Mono use "oblique" instead of "italic"
|
||||
boolean useOblique =
|
||||
baseFontId.equals("fallback-dejavu-sans")
|
||||
|| baseFontId.equals("fallback-dejavu-mono");
|
||||
"fallback-dejavu-sans".equals(baseFontId)
|
||||
|| "fallback-dejavu-mono".equals(baseFontId);
|
||||
|
||||
if (isBold && isItalic) {
|
||||
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -27,6 +28,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
||||
@Slf4j
|
||||
public class SharedSignatureService {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -105,7 +107,7 @@ public class SharedSignatureService {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
@@ -113,7 +115,7 @@ public class SharedSignatureService {
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
// Whitelist only safe image extensions
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
|
||||
@@ -64,7 +64,9 @@ security:
|
||||
persistence: true # Set to 'true' to enable JWT key store
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
keyRetentionDays: 30 # Number of days to retain old keys. The default is 30 days.
|
||||
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes (1 day).
|
||||
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
@@ -230,10 +232,12 @@ ui:
|
||||
appNameNavbar: "" # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
|
||||
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
|
||||
|
||||
endpoints:
|
||||
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
|
||||
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
|
||||
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice', 'DeveloperTools', 'DeveloperDocs', 'Automation'])
|
||||
|
||||
metrics:
|
||||
enabled: true # 'true' to enable Info APIs (`/api/*`) endpoints, 'false' to disable
|
||||
|
||||
+4
-2
@@ -18,6 +18,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -46,6 +47,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
public class ConvertWebsiteToPdfTest {
|
||||
|
||||
private static final Pattern PDF_FILENAME_PATTERN = Pattern.compile("[A-Za-z0-9_]+\\.pdf");
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@@ -142,7 +144,7 @@ public class ConvertWebsiteToPdfTest {
|
||||
|
||||
assertTrue(out.endsWith(".pdf"));
|
||||
// Only A–Z, a–z, 0–9, underscore and dot allowed
|
||||
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
|
||||
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
|
||||
// no truncation here (source not that long)
|
||||
assertTrue(out.length() <= 54);
|
||||
}
|
||||
@@ -159,7 +161,7 @@ public class ConvertWebsiteToPdfTest {
|
||||
String out = (String) m.invoke(sut, longUrl);
|
||||
|
||||
assertTrue(out.endsWith(".pdf"));
|
||||
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
|
||||
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
|
||||
// safeName limited to 50 -> total max 54 including '.pdf'
|
||||
assertTrue(out.length() <= 54, "Filename should be truncated to 50 + '.pdf'");
|
||||
}
|
||||
|
||||
+25
-9
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
@@ -26,6 +27,18 @@ import stirling.software.common.util.TempFileManager;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StampControllerTest {
|
||||
|
||||
private static final Pattern UUID_HEX_PATTERN = Pattern.compile("[0-9a-f]{8}");
|
||||
private static final Pattern DATE_LITERAL_REGEX =
|
||||
Pattern.compile("@date is \\d{4}-\\d{2}-\\d{2}");
|
||||
private static final Pattern DATE_TIME_MIN_PATTERN =
|
||||
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}");
|
||||
private static final Pattern DATE_SLASH_PATTERN = Pattern.compile("\\d{2}/\\d{2}/\\d{4}");
|
||||
private static final Pattern DAY_LABEL_PATTERN = Pattern.compile("Day: \\d{2}");
|
||||
private static final Pattern MONTH_LABEL_PATTERN = Pattern.compile("Month: \\d{2}");
|
||||
private static final Pattern DATE_TIME_FULL_PATTERN =
|
||||
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
|
||||
private static final Pattern TIME_LABEL_PATTERN = Pattern.compile("Time: \\d{2}:\\d{2}:\\d{2}");
|
||||
private static final Pattern DATE_LABEL_PATTERN = Pattern.compile("Date: \\d{4}-\\d{2}-\\d{2}");
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@@ -173,7 +186,7 @@ class StampControllerTest {
|
||||
void testDateReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
|
||||
DATE_LABEL_PATTERN.matcher(result).matches(),
|
||||
"Date should match YYYY-MM-DD format");
|
||||
}
|
||||
|
||||
@@ -182,7 +195,7 @@ class StampControllerTest {
|
||||
void testTimeReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
|
||||
TIME_LABEL_PATTERN.matcher(result).matches(),
|
||||
"Time should match HH:mm:ss format");
|
||||
}
|
||||
|
||||
@@ -192,7 +205,7 @@ class StampControllerTest {
|
||||
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
|
||||
// DateTime format: YYYY-MM-DD HH:mm:ss
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
|
||||
DATE_TIME_FULL_PATTERN.matcher(result).matches(),
|
||||
"DateTime should match YYYY-MM-DD HH:mm:ss format");
|
||||
}
|
||||
|
||||
@@ -208,14 +221,15 @@ class StampControllerTest {
|
||||
@DisplayName("Should replace @month with zero-padded month")
|
||||
void testMonthReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
|
||||
assertTrue(
|
||||
MONTH_LABEL_PATTERN.matcher(result).matches(), "Month should be zero-padded");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @day with zero-padded day")
|
||||
void testDayReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
|
||||
assertTrue(DAY_LABEL_PATTERN.matcher(result).matches(), "Day should be zero-padded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +242,7 @@ class StampControllerTest {
|
||||
void testCustomDateFormatSlash() throws Exception {
|
||||
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{2}/\\d{2}/\\d{4}"),
|
||||
DATE_SLASH_PATTERN.matcher(result).matches(),
|
||||
"Should match dd/MM/yyyy format: " + result);
|
||||
}
|
||||
|
||||
@@ -238,7 +252,7 @@ class StampControllerTest {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
|
||||
DATE_TIME_MIN_PATTERN.matcher(result).matches(),
|
||||
"Should match yyyy-MM-dd HH:mm format: " + result);
|
||||
}
|
||||
|
||||
@@ -345,7 +359,7 @@ class StampControllerTest {
|
||||
// @@date should become @date, and @date should be replaced with actual date
|
||||
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
|
||||
assertTrue(
|
||||
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
|
||||
DATE_LITERAL_REGEX.matcher(result).matches(),
|
||||
"Should have date after: " + result);
|
||||
}
|
||||
|
||||
@@ -463,7 +477,9 @@ class StampControllerTest {
|
||||
@DisplayName("UUID should contain only hex characters")
|
||||
void testUuidFormat() throws Exception {
|
||||
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
|
||||
assertTrue(
|
||||
UUID_HEX_PATTERN.matcher(result).matches(),
|
||||
"UUID should be 8 hex characters: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -104,12 +105,40 @@ public class FormFillController {
|
||||
|
||||
requirePdf(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
FormUtils.FormFieldExtraction extraction =
|
||||
FormUtils.extractFieldsWithTemplate(document);
|
||||
return ResponseEntity.ok(extraction);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/fields-with-coordinates", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Inspect PDF form fields with widget coordinates",
|
||||
description =
|
||||
"Returns metadata describing each field in the provided PDF form, "
|
||||
+ "including precise widget coordinates for interactive rendering")
|
||||
public ResponseEntity<List<FormFieldWithCoordinates>> listFieldsWithCoordinates(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file)
|
||||
throws IOException {
|
||||
|
||||
requirePdf(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(document);
|
||||
return ResponseEntity.ok(fields);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Modify existing form fields",
|
||||
@@ -215,6 +244,7 @@ public class FormFillController {
|
||||
|
||||
String baseName = buildBaseName(file, suffix);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
processor.accept(document);
|
||||
return saveDocument(document, baseName);
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package stirling.software.proprietary.security.configuration.ee;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.service.LicenseServiceInterface;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
|
||||
/**
|
||||
* Service that provides dynamic license checking instead of cached beans. This ensures that when
|
||||
* admins update the license key, the changes are immediately reflected in the UI and config
|
||||
* endpoints without requiring a restart.
|
||||
*
|
||||
* <p>Note: Some components (EnterpriseEndpointAspect, PremiumEndpointAspect, filters) still inject
|
||||
* cached beans at startup for performance. These will require a restart to reflect license changes.
|
||||
* This is acceptable because: 1. Most deployments add licenses during initial setup 2. License
|
||||
* changes in production typically warrant a restart anyway 3. UI reflects changes immediately
|
||||
* (banner disappears, license status updates)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DynamicLicenseService implements LicenseServiceInterface {
|
||||
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Get the current license type dynamically (not cached).
|
||||
*
|
||||
* @return Current license: NORMAL, SERVER, or ENTERPRISE
|
||||
*/
|
||||
public License getCurrentLicense() {
|
||||
return licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunningProOrHigher() {
|
||||
License license = getCurrentLicense();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunningEE() {
|
||||
return getCurrentLicense() == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLicenseTypeName() {
|
||||
return getCurrentLicense().name();
|
||||
}
|
||||
}
|
||||
+24
-4
@@ -34,6 +34,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
@@ -180,7 +181,12 @@ public class AuthController {
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
token,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds())));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
String username = request.getUsername();
|
||||
@@ -272,8 +278,7 @@ public class AuthController {
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
String username = jwtService.extractUsernameAllowExpired(token);
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
@@ -289,8 +294,17 @@ public class AuthController {
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", newToken, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
newToken,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds())));
|
||||
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -532,6 +546,12 @@ public class AuthController {
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : 720;
|
||||
return expiryMinutes * 60L;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -36,6 +37,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
|
||||
@RequiredArgsConstructor
|
||||
public class UIDataTessdataController {
|
||||
|
||||
private static final Pattern INVALID_LANG_CHARS_PATTERN = Pattern.compile("[^A-Za-z0-9_+\\-]");
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private static volatile List<String> cachedRemoteTessdata = null;
|
||||
private static volatile long cachedRemoteTessdataExpiry = 0L;
|
||||
@@ -88,7 +90,7 @@ public class UIDataTessdataController {
|
||||
failed.add(language);
|
||||
continue;
|
||||
}
|
||||
String safeLang = language.replaceAll("[^A-Za-z0-9_+\\-]", "");
|
||||
String safeLang = INVALID_LANG_CHARS_PATTERN.matcher(language).replaceAll("");
|
||||
if (!safeLang.equals(language)) {
|
||||
failed.add(language);
|
||||
continue;
|
||||
|
||||
+18
-17
@@ -69,27 +69,28 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
if (!apiKeyExists(request, response)) {
|
||||
String jwtToken = jwtService.extractToken(request);
|
||||
|
||||
if (jwtToken == null) {
|
||||
// Allow auth endpoints to pass through without JWT
|
||||
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|| (acceptHeader != null
|
||||
&& acceptHeader.contains("application/json"))) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Authentication required\"}");
|
||||
return;
|
||||
}
|
||||
// Check if this is a public endpoint BEFORE validating JWT
|
||||
// This allows public endpoints to work even with expired tokens in the request
|
||||
if (isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||
// For public auth endpoints, skip JWT validation and continue
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML requests (SPA routes), let React Router handle it (serve
|
||||
// index.html)
|
||||
filterChain.doFilter(request, response);
|
||||
if (jwtToken == null) {
|
||||
// No JWT token and not a public endpoint
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|| (acceptHeader != null && acceptHeader.contains("application/json"))) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Authentication required\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// For public auth endpoints without JWT, continue to the endpoint
|
||||
// For HTML requests (SPA routes), let React Router handle it (serve
|
||||
// index.html)
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
+40
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
|
||||
|
||||
@Slf4j
|
||||
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
|
||||
@@ -33,9 +34,33 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
|
||||
|
||||
if (exception instanceof Saml2AuthenticationException) {
|
||||
Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error();
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
String redirectUrl =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", error.getErrorCode());
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
}
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(request, response, "/login?errorOAuth=" + error.getErrorCode());
|
||||
} else if (exception instanceof ProviderNotFoundException) {
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
String redirectUrl =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
redirectUrl =
|
||||
appendQueryParam(
|
||||
redirectUrl, "errorOAuth", "not_authentication_provider_found");
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
}
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(
|
||||
request,
|
||||
@@ -43,4 +68,19 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
|
||||
"/login?errorOAuth=not_authentication_provider_found");
|
||||
}
|
||||
}
|
||||
|
||||
private String appendQueryParam(String path, String key, String value) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
String encodedKey =
|
||||
java.net.URLEncoder.encode(key, java.nio.charset.StandardCharsets.UTF_8);
|
||||
String encodedValue =
|
||||
value == null
|
||||
? ""
|
||||
: java.net.URLEncoder.encode(
|
||||
value, java.nio.charset.StandardCharsets.UTF_8);
|
||||
return path + separator + encodedKey + "=" + encodedValue;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -33,6 +33,7 @@ import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -233,7 +234,16 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
String redirectPath = resolveRedirectPath(request, contextPath);
|
||||
String origin = resolveOrigin(request);
|
||||
clearRedirectCookie(response);
|
||||
return origin + redirectPath + "#access_token=" + jwt;
|
||||
String url = origin + redirectPath + "#access_token=" + jwt;
|
||||
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
url +=
|
||||
"&nonce="
|
||||
+ java.net.URLEncoder.encode(
|
||||
nonce, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +266,9 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
}
|
||||
|
||||
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
|
||||
}
|
||||
return extractRedirectPathFromCookie(request)
|
||||
.filter(path -> path.startsWith("/"))
|
||||
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||
|
||||
+10
@@ -156,6 +156,16 @@ public class Saml2Configuration {
|
||||
OpenSaml4AuthenticationRequestResolver resolver =
|
||||
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository);
|
||||
|
||||
resolver.setRelayStateResolver(
|
||||
request -> {
|
||||
String tauriParam = request.getParameter("tauri");
|
||||
if (!"1".equals(tauriParam)) {
|
||||
return null;
|
||||
}
|
||||
String nonce = request.getParameter("nonce");
|
||||
return TauriSamlUtils.buildRelayState(nonce);
|
||||
});
|
||||
|
||||
resolver.setAuthnRequestCustomizer(
|
||||
customizer -> {
|
||||
HttpServletRequest request = customizer.getRequest();
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/** Utility helpers for the Tauri desktop SAML flow. */
|
||||
public final class TauriSamlUtils {
|
||||
|
||||
public static final String TAURI_RELAY_STATE_PREFIX = "tauri:";
|
||||
|
||||
private TauriSamlUtils() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
public static boolean isTauriRelayState(HttpServletRequest request) {
|
||||
String relayState = request.getParameter("RelayState");
|
||||
return relayState != null
|
||||
&& (relayState.equals("tauri") || relayState.startsWith(TAURI_RELAY_STATE_PREFIX));
|
||||
}
|
||||
|
||||
public static String extractNonceFromRelayState(String relayState) {
|
||||
if (relayState == null || !relayState.startsWith(TAURI_RELAY_STATE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = relayState.split(":");
|
||||
if (parts.length >= 2) {
|
||||
String nonce = parts[parts.length - 1];
|
||||
return nonce.isBlank() ? null : nonce;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String extractNonceFromRequest(HttpServletRequest request) {
|
||||
return extractNonceFromRelayState(request.getParameter("RelayState"));
|
||||
}
|
||||
|
||||
public static String buildRelayState(String nonce) {
|
||||
if (nonce == null || nonce.isBlank()) {
|
||||
return "tauri";
|
||||
}
|
||||
return TAURI_RELAY_STATE_PREFIX + nonce;
|
||||
}
|
||||
}
|
||||
+49
-4
@@ -30,6 +30,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
@@ -39,17 +40,21 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 43200000;
|
||||
private static final long ONE_MINUTE_MILLIS = 60_000L;
|
||||
private static final int DEFAULT_EXPIRY_MINUTES = 720;
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Autowired
|
||||
public JwtService(
|
||||
@Qualifier("v2Enabled") boolean v2Enabled,
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
KeyPersistenceServiceInterface keyPersistenceService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
this.securityProperties = applicationProperties.getSecurity();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -86,7 +91,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
.subject(username)
|
||||
.issuer(ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
||||
.expiration(
|
||||
new Date(System.currentTimeMillis() + getExpirationMillis()))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
@@ -114,12 +120,23 @@ public class JwtService implements JwtServiceInterface {
|
||||
return extractClaim(token, Claims::getSubject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String extractUsernameAllowExpired(String token) {
|
||||
return extractClaim(token, Claims::getSubject, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaims(String token) {
|
||||
Claims claims = extractAllClaims(token);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaimsAllowExpired(String token) {
|
||||
Claims claims = extractAllClaims(token, true);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTokenExpired(String token) {
|
||||
return extractExpiration(token).before(new Date());
|
||||
@@ -130,11 +147,21 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Claims claims = extractAllClaims(token);
|
||||
final Claims claims = extractAllClaims(token, false);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private <T> T extractClaim(
|
||||
String token, Function<Claims, T> claimsResolver, boolean allowExpired) {
|
||||
final Claims claims = extractAllClaims(token, allowExpired);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token) {
|
||||
return extractAllClaims(token, false);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token, boolean allowExpired) {
|
||||
try {
|
||||
String keyId = extractKeyId(token);
|
||||
KeyPair keyPair;
|
||||
@@ -181,6 +208,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
return Jwts.parser()
|
||||
.verifyWith(keyPair.getPublic())
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -191,6 +219,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
log.warn("Invalid token: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("Invalid token", e);
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
return e.getClaims();
|
||||
}
|
||||
log.warn("The token has expired: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (UnsupportedJwtException e) {
|
||||
@@ -210,6 +241,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -230,6 +262,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
verificationKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -276,6 +309,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
(String)
|
||||
Jwts.parser()
|
||||
.verifyWith(signingKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
@@ -286,4 +320,15 @@ public class JwtService implements JwtServiceInterface {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private long getExpirationMillis() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : DEFAULT_EXPIRY_MINUTES;
|
||||
return expiryMinutes * ONE_MINUTE_MILLIS;
|
||||
}
|
||||
|
||||
private long getAllowedClockSkewSeconds() {
|
||||
int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
|
||||
return configuredSeconds >= 0 ? configuredSeconds : 60L;
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -41,6 +41,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractUsername(String token);
|
||||
|
||||
/**
|
||||
* Extract username from JWT token while allowing expired tokens. Signature and token structure
|
||||
* must still be valid.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return username extracted from token
|
||||
*/
|
||||
String extractUsernameAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Extract all claims from JWT token
|
||||
*
|
||||
@@ -49,6 +58,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
Map<String, Object> extractClaims(String token);
|
||||
|
||||
/**
|
||||
* Extract all claims from JWT token while allowing expired tokens. Signature and token
|
||||
* structure must still be valid.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return map of claims
|
||||
*/
|
||||
Map<String, Object> extractClaimsAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*
|
||||
|
||||
+3
-1
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
@@ -33,6 +34,7 @@ public class TotpService {
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA1";
|
||||
private static final String DEFAULT_ISSUER = "Stirling PDF";
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final Pattern TOTP_CODE_PATTERN = Pattern.compile("\\d{6}");
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -71,7 +73,7 @@ public class TotpService {
|
||||
}
|
||||
|
||||
String normalizedCode = code.replace(" ", "");
|
||||
if (!normalizedCode.matches("\\d{6}")) {
|
||||
if (!TOTP_CODE_PATTERN.matcher(normalizedCode).matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -10,6 +10,7 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -32,6 +33,7 @@ import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||
@Slf4j
|
||||
public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
@@ -366,14 +368,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -18,6 +19,7 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
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.pdmodel.PDDocument;
|
||||
@@ -46,6 +48,7 @@ import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -67,6 +70,13 @@ public class FormUtils {
|
||||
public final Set<String> CHOICE_FIELD_TYPES =
|
||||
Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO);
|
||||
|
||||
/**
|
||||
* Threshold in PDF points for considering two widgets to be on the same line. Fields whose
|
||||
* y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead
|
||||
* of top-to-bottom.
|
||||
*/
|
||||
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
|
||||
|
||||
/**
|
||||
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
|
||||
* so all callers share identical mapping logic.
|
||||
@@ -109,6 +119,8 @@ public class FormUtils {
|
||||
List<FormFieldInfo> fields = new ArrayList<>();
|
||||
Map<String, Integer> typeCounters = new HashMap<>();
|
||||
Map<Integer, Integer> pageOrderCounters = new HashMap<>();
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
@@ -125,7 +137,7 @@ public class FormUtils {
|
||||
|
||||
String currentValue = safeValue(terminalField);
|
||||
boolean required = field.isRequired();
|
||||
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField);
|
||||
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField, annotationPageMap);
|
||||
List<String> options = resolveOptions(terminalField);
|
||||
String tooltip = resolveTooltip(terminalField);
|
||||
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||
@@ -164,6 +176,396 @@ public class FormUtils {
|
||||
return Collections.unmodifiableList(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form fields with widget coordinates for the interactive form viewer.
|
||||
*
|
||||
* @param document PDF document
|
||||
* @return List of form fields with coordinates and metadata
|
||||
*/
|
||||
public List<FormFieldWithCoordinates> extractFormFieldsWithCoordinates(PDDocument document) {
|
||||
if (document == null) return List.of();
|
||||
|
||||
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||
if (acroForm == null) return List.of();
|
||||
|
||||
List<FormFieldWithCoordinates> fields = new ArrayList<>();
|
||||
Map<String, Integer> typeCounters = new HashMap<>();
|
||||
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String type = detectFieldType(terminalField);
|
||||
String name =
|
||||
Optional.ofNullable(field.getFullyQualifiedName())
|
||||
.orElseGet(field::getPartialName);
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String currentValue = safeValue(terminalField);
|
||||
boolean required = field.isRequired();
|
||||
boolean readOnly = field.isReadOnly();
|
||||
List<String> options = resolveOptions(terminalField);
|
||||
List<String> displayOptions = resolveDisplayOptions(terminalField);
|
||||
String tooltip = resolveTooltip(terminalField);
|
||||
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||
String displayLabel =
|
||||
deriveDisplayLabel(field, name, tooltip, type, typeIndex, options);
|
||||
boolean multiSelect = resolveMultiSelect(terminalField);
|
||||
boolean multiline =
|
||||
terminalField instanceof PDTextField
|
||||
&& ((PDTextField) terminalField).isMultiline();
|
||||
|
||||
// Extract widget coordinates
|
||||
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
|
||||
extractWidgetCoordinates(document, terminalField, annotationPageMap);
|
||||
|
||||
// Only include displayOptions when they differ from export options
|
||||
List<String> displayOptsToSend = null;
|
||||
if (displayOptions != null
|
||||
&& !displayOptions.isEmpty()
|
||||
&& !displayOptions.equals(options)) {
|
||||
displayOptsToSend = displayOptions;
|
||||
}
|
||||
|
||||
fields.add(
|
||||
FormFieldWithCoordinates.builder()
|
||||
.name(name)
|
||||
.label(displayLabel)
|
||||
.type(type)
|
||||
.value(currentValue)
|
||||
.options(options.isEmpty() ? null : options)
|
||||
.displayOptions(displayOptsToSend)
|
||||
.required(required)
|
||||
.readOnly(readOnly)
|
||||
.multiSelect(multiSelect)
|
||||
.multiline(multiline)
|
||||
.tooltip(tooltip)
|
||||
.widgets(widgets.isEmpty() ? null : widgets)
|
||||
.build());
|
||||
}
|
||||
|
||||
// Sort by page and position
|
||||
fields.sort(new FieldCoordinateComparator());
|
||||
|
||||
log.debug("Total fields processed: {}", fields.size());
|
||||
log.debug(
|
||||
"Fields WITH widgets: {}",
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
.count());
|
||||
log.debug(
|
||||
"Fields WITHOUT widgets: {}",
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||
.count());
|
||||
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||
.forEach(
|
||||
f ->
|
||||
log.debug(
|
||||
"Field '{}' type={} has NO widget coordinates",
|
||||
f.getName(),
|
||||
f.getType()));
|
||||
|
||||
return Collections.unmodifiableList(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract widget coordinates for a form field.
|
||||
*
|
||||
* @param document PDF document
|
||||
* @param field Terminal field
|
||||
* @return List of widget coordinates
|
||||
*/
|
||||
private List<FormFieldWithCoordinates.WidgetCoordinates> extractWidgetCoordinates(
|
||||
PDDocument document,
|
||||
PDTerminalField field,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
List<FormFieldWithCoordinates.WidgetCoordinates> result = new ArrayList<>();
|
||||
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
|
||||
log.debug(
|
||||
"Field '{}' type={} has {} widgets",
|
||||
field.getFullyQualifiedName(),
|
||||
field.getClass().getSimpleName(),
|
||||
widgets != null ? widgets.size() : 0);
|
||||
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
// Some fields (especially text fields) might be their own widget annotation
|
||||
log.trace(
|
||||
"Field '{}' has no widgets, checking if field acts as its own annotation",
|
||||
field.getFullyQualifiedName());
|
||||
try {
|
||||
COSDictionary fieldDict = field.getCOSObject();
|
||||
COSBase rectBase = fieldDict.getDictionaryObject(COSName.RECT);
|
||||
if (rectBase instanceof COSArray rectArray) {
|
||||
int pageIndex =
|
||||
findPageIndexForAnnotation(document, fieldDict, annotationPageMap);
|
||||
if (pageIndex >= 0) {
|
||||
PDRectangle rectangle = new PDRectangle(rectArray);
|
||||
result.add(
|
||||
createWidgetCoordinates(
|
||||
document, rectangle, pageIndex, null, field));
|
||||
} else {
|
||||
log.warn(
|
||||
"Found rectangle for field '{}' but could not resolve page index",
|
||||
field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Could not extract direct rectangle for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For radio buttons, pre-resolve export values per widget
|
||||
List<String> exportValues = null;
|
||||
if (field instanceof PDRadioButton radio) {
|
||||
exportValues = radio.getExportValues();
|
||||
}
|
||||
|
||||
for (int i = 0; i < widgets.size(); i++) {
|
||||
PDAnnotationWidget widget = widgets.get(i);
|
||||
try {
|
||||
PDRectangle rectangle = widget.getRectangle();
|
||||
if (rectangle == null) {
|
||||
log.warn(
|
||||
"Field '{}' widget {} has NULL rectangle",
|
||||
field.getFullyQualifiedName(),
|
||||
i);
|
||||
continue;
|
||||
}
|
||||
|
||||
int pageIndex = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (pageIndex < 0) {
|
||||
log.warn(
|
||||
"Field '{}' widget {} could not resolve page index",
|
||||
field.getFullyQualifiedName(),
|
||||
i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve export value for radio/checkbox widgets
|
||||
String exportValue = null;
|
||||
if (exportValues != null && i < exportValues.size()) {
|
||||
exportValue = exportValues.get(i);
|
||||
} else if (field instanceof PDButton) {
|
||||
// Fall back to appearance state name from the widget's normal appearance
|
||||
try {
|
||||
var ap = widget.getAppearance();
|
||||
if (ap != null && ap.getNormalAppearance() != null) {
|
||||
var normalAp = ap.getNormalAppearance();
|
||||
if (normalAp.isSubDictionary()) {
|
||||
for (var cosName : normalAp.getSubDictionary().keySet()) {
|
||||
String key = cosName.getName();
|
||||
if (!"Off".equals(key)) {
|
||||
exportValue = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace(
|
||||
"Could not extract export value for widget in '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
result.add(
|
||||
createWidgetCoordinates(
|
||||
document, rectangle, pageIndex, exportValue, field));
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to extract coordinates for widget in field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates(
|
||||
PDDocument document,
|
||||
PDRectangle rectangle,
|
||||
int pageIndex,
|
||||
String exportValue,
|
||||
PDTerminalField field) {
|
||||
if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
PDRectangle cropBox = page.getCropBox();
|
||||
|
||||
// Use CropBox dimensions for the y-flip.
|
||||
// Note: getWidth() and getHeight() return dimensions BEFORE rotation.
|
||||
float cropHeight = cropBox.getHeight();
|
||||
|
||||
// Get absolute widget coordinates (in MediaBox space, un-rotated)
|
||||
float pdfX = rectangle.getLowerLeftX();
|
||||
float pdfY = rectangle.getLowerLeftY();
|
||||
float width = rectangle.getWidth();
|
||||
float height = rectangle.getHeight();
|
||||
|
||||
// Adjust relative to CropBox origin
|
||||
float relativeX = pdfX - cropBox.getLowerLeftX();
|
||||
float relativeY = pdfY - cropBox.getLowerLeftY();
|
||||
|
||||
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
|
||||
// Widget /Rect coordinates are always in un-rotated PDF user space.
|
||||
// The embedpdf viewer wraps all page content inside a <Rotate> CSS
|
||||
// component that handles visual rotation — we must NOT apply any
|
||||
// rotation transform here, or widgets would be double-rotated.
|
||||
float finalX = relativeX;
|
||||
float finalY = cropHeight - relativeY - height;
|
||||
float finalW = width;
|
||||
float finalH = height;
|
||||
|
||||
// Validate coordinates are within reasonable bounds
|
||||
if (finalX < -1.0f
|
||||
|| finalY < -1.0f
|
||||
|| finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow
|
||||
|| finalY > cropHeight + 1.0f) {
|
||||
log.warn(
|
||||
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}",
|
||||
field.getFullyQualifiedName(),
|
||||
pageIndex,
|
||||
finalX,
|
||||
finalY,
|
||||
finalW,
|
||||
finalH);
|
||||
return null;
|
||||
}
|
||||
|
||||
return FormFieldWithCoordinates.WidgetCoordinates.builder()
|
||||
.pageIndex(pageIndex)
|
||||
.x(finalX)
|
||||
.y(finalY)
|
||||
.width(finalW)
|
||||
.height(finalH)
|
||||
.exportValue(exportValue)
|
||||
.fontSize(extractFontSize(field))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs widgets with missing page references by scanning all pages and setting the /P entry
|
||||
* for orphan widgets.
|
||||
*
|
||||
* <p>This should be called BEFORE extracting form field coordinates.
|
||||
*
|
||||
* @param document PDF document to repair
|
||||
*/
|
||||
public void repairMissingWidgetPageReferences(PDDocument document) {
|
||||
try {
|
||||
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||
if (acroForm == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Checking for widgets with missing page references...");
|
||||
int repairedCount = 0;
|
||||
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<PDAnnotationWidget> widgets = terminalField.getWidgets();
|
||||
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
if (widget.getPage() == null) {
|
||||
Integer pageIndex = annotationPageMap.get(widget.getCOSObject());
|
||||
if (pageIndex != null && pageIndex >= 0) {
|
||||
PDPage foundPage = document.getPage(pageIndex);
|
||||
widget.setPage(foundPage);
|
||||
repairedCount++;
|
||||
log.debug(
|
||||
"Repaired widget for field '{}' - set page reference via map",
|
||||
field.getFullyQualifiedName());
|
||||
} else {
|
||||
log.warn(
|
||||
"Could not find page for widget in field '{}'",
|
||||
field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (repairedCount > 0) {
|
||||
log.debug(
|
||||
"Successfully repaired {} widgets with missing page references",
|
||||
repairedCount);
|
||||
} else {
|
||||
log.debug("No widgets needed repair");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error repairing widget page references: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private int findPageIndexForAnnotation(
|
||||
PDDocument document,
|
||||
COSDictionary annotDict,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
try {
|
||||
// Method 0: Check the pre-built lookup map (fastest)
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(annotDict);
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 1: Check the /P entry if it points to a page
|
||||
COSBase base = annotDict.getDictionaryObject(COSName.P);
|
||||
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||
if (pageDict != null) {
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Fallback search through all pages' annotations
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
if (annotations != null) {
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null && annot.getCOSObject() == annotDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("Error finding page for annotation: {}", e.getMessage());
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single record object (field-name -> value placeholder) that can be directly submitted
|
||||
* to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless
|
||||
@@ -312,7 +714,24 @@ public class FormUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
flattenViaRendering(document, acroForm);
|
||||
if (acroForm == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use PDFBox's built-in field flattening which bakes form field values
|
||||
// into the page content stream as static text/graphics, removing the
|
||||
// interactive form structure but preserving all other document content
|
||||
// (images, text, annotations, etc.) at full quality.
|
||||
try {
|
||||
ensureAppearances(acroForm);
|
||||
acroForm.flatten();
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"PDFBox acroForm.flatten() failed, falling back to rendering: {}",
|
||||
e.getMessage(),
|
||||
e);
|
||||
flattenViaRendering(document, acroForm);
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
|
||||
@@ -385,7 +804,7 @@ public class FormUtils {
|
||||
|
||||
PDPage page = widget.getPage();
|
||||
if (page == null) {
|
||||
page = resolveWidgetPage(document, widget);
|
||||
page = resolveWidgetPage(document, widget, null);
|
||||
if (page != null) {
|
||||
widget.setPage(page);
|
||||
}
|
||||
@@ -820,6 +1239,16 @@ public class FormUtils {
|
||||
|
||||
private String safeValue(PDTerminalField field) {
|
||||
try {
|
||||
// PDChoice.getValueAsString() returns a raw COS string representation
|
||||
// that doesn't reliably reflect the selected value. Use getValue()
|
||||
// which returns the proper List<String> of selected options.
|
||||
if (field instanceof PDChoice choiceField) {
|
||||
List<String> selected = choiceField.getValue();
|
||||
if (selected == null || selected.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return String.join(",", selected);
|
||||
}
|
||||
return field.getValueAsString();
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
@@ -833,14 +1262,25 @@ public class FormUtils {
|
||||
List<String> resolveOptions(PDTerminalField field) {
|
||||
try {
|
||||
if (field instanceof PDChoice choice) {
|
||||
List<String> display = choice.getOptionsDisplayValues();
|
||||
if (display != null && !display.isEmpty()) {
|
||||
return new ArrayList<>(display);
|
||||
}
|
||||
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||
List<String> exportValues = choice.getOptionsExportValues();
|
||||
if (exportValues != null && !exportValues.isEmpty()) {
|
||||
return new ArrayList<>(exportValues);
|
||||
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||
|
||||
if (exportValues != null) {
|
||||
exportValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
if (displayValues != null) {
|
||||
displayValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
return new ArrayList<>(allowed);
|
||||
} else if (field instanceof PDRadioButton radio) {
|
||||
List<String> exports = radio.getExportValues();
|
||||
if (exports != null && !exports.isEmpty()) {
|
||||
@@ -861,6 +1301,29 @@ public class FormUtils {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display-value labels for a choice field's options. For radio / checkbox this
|
||||
* returns an empty list (no separate display values). For PDChoice fields, if the PDF provides
|
||||
* distinct display values, those are returned; otherwise an empty list (indicating that the
|
||||
* export values from {@link #resolveOptions} should be shown directly).
|
||||
*/
|
||||
List<String> resolveDisplayOptions(PDTerminalField field) {
|
||||
try {
|
||||
if (field instanceof PDChoice choice) {
|
||||
List<String> display = choice.getOptionsDisplayValues();
|
||||
if (display != null && !display.isEmpty()) {
|
||||
return new ArrayList<>(display);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to resolve display options for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private boolean resolveMultiSelect(PDTerminalField field) {
|
||||
if (field instanceof PDListBox listBox) {
|
||||
try {
|
||||
@@ -875,6 +1338,44 @@ public class FormUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
private Float extractFontSize(PDTerminalField field) {
|
||||
try {
|
||||
String da = null;
|
||||
if (field instanceof PDVariableText vt) {
|
||||
da = vt.getDefaultAppearance();
|
||||
}
|
||||
|
||||
if (da == null || da.isBlank()) {
|
||||
// Check parent/acroform default appearance if field's is missing
|
||||
PDAcroForm form = field.getAcroForm();
|
||||
if (form != null) {
|
||||
da = form.getDefaultAppearance();
|
||||
}
|
||||
}
|
||||
|
||||
if (da != null && !da.isBlank()) {
|
||||
// Standard DA looks like: /Helv 12 Tf 0 g
|
||||
// We want the number before 'Tf'
|
||||
String[] tokens = da.split("\\s+");
|
||||
for (int i = 0; i < tokens.length; i++) {
|
||||
if ("Tf".equals(tokens[i]) && i > 0) {
|
||||
try {
|
||||
float size = Float.parseFloat(tokens[i - 1]);
|
||||
return size > 0 ? size : null;
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace(
|
||||
"Could not extract font size for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isSettableCheckBoxState(String state) {
|
||||
if (state == null) return false;
|
||||
String trimmed = state.trim();
|
||||
@@ -952,6 +1453,13 @@ public class FormUtils {
|
||||
|
||||
if (simplified.isEmpty()) return true;
|
||||
|
||||
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
|
||||
// Standard UUIDs are 32 hex characters; require at least that to avoid
|
||||
// false positives on short hex-like field names.
|
||||
String nospaces = simplified.replaceAll("\\s+", "");
|
||||
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
|
||||
return true;
|
||||
|
||||
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|
||||
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|
||||
|| patterns.getOptionalTNumericPattern().matcher(simplified).matches();
|
||||
@@ -1007,7 +1515,7 @@ public class FormUtils {
|
||||
|
||||
PDAnnotationWidget widget = widgets.get(0);
|
||||
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
|
||||
PDPage page = resolveWidgetPage(document, widget);
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page == null || originalRectangle == null) {
|
||||
log.warn(
|
||||
"Unable to resolve widget page or rectangle for '{}'; skipping",
|
||||
@@ -1064,7 +1572,7 @@ public class FormUtils {
|
||||
desiredName,
|
||||
modification.label(),
|
||||
resolvedType,
|
||||
determineWidgetPageIndex(document, widget),
|
||||
determineWidgetPageIndex(document, widget, null),
|
||||
originalRectangle.getLowerLeftX(),
|
||||
originalRectangle.getLowerLeftY(),
|
||||
originalRectangle.getWidth(),
|
||||
@@ -1205,59 +1713,43 @@ public class FormUtils {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) {
|
||||
private int resolveFirstWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDTerminalField field,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
Map<PDAnnotationWidget, Integer> widgetPageFallbacks = null;
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
int idx = resolveWidgetPageIndex(document, widget);
|
||||
int idx = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
}
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null
|
||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
||||
if (widgetPageFallbacks == null) {
|
||||
widgetPageFallbacks = buildWidgetPageFallbackMap(document);
|
||||
}
|
||||
Integer fallbackIndex = widgetPageFallbacks.get(widget);
|
||||
if (fallbackIndex != null && fallbackIndex >= 0) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to inspect widget page reference for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
||||
private int resolveWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (document == null || widget == null) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null
|
||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
||||
Map<PDAnnotationWidget, Integer> fallback = buildWidgetPageFallbackMap(document);
|
||||
Integer index = fallback.get(widget);
|
||||
if (index != null) {
|
||||
return index;
|
||||
}
|
||||
|
||||
// Method 0: Check the pre-built lookup map (fastest)
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Widget page lookup via fallback map failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
PDPage page = widget.getPage();
|
||||
if (page != null) {
|
||||
// indexOf is O(N), still slower than map but better than scanning annotations
|
||||
int idx = document.getPages().indexOf(page);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
@@ -1267,14 +1759,36 @@ public class FormUtils {
|
||||
log.debug("Widget page lookup failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Method 1: Check the /P entry if it points to a page
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null) {
|
||||
COSBase base = widgetDictionary.getDictionaryObject(COSName.P);
|
||||
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||
if (pageDict != null) {
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Widget page lookup via /P entry failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Method 2: Fallback search through all pages' annotations
|
||||
int pageCount = document.getNumberOfPages();
|
||||
COSDictionary widgetDict = widget.getCOSObject();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try {
|
||||
PDPage candidate = document.getPage(i);
|
||||
List<PDAnnotation> annotations = candidate.getAnnotations();
|
||||
for (PDAnnotation annotation : annotations) {
|
||||
if (annotation == widget) {
|
||||
return i;
|
||||
if (annotations != null) {
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null && annot.getCOSObject() == widgetDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
@@ -1317,7 +1831,7 @@ public class FormUtils {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets != null) {
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
PDPage page = resolveWidgetPage(document, widget);
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page != null) {
|
||||
page.getAnnotations().remove(widget);
|
||||
}
|
||||
@@ -1437,7 +1951,10 @@ public class FormUtils {
|
||||
rectangle.getHeight());
|
||||
}
|
||||
|
||||
private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) {
|
||||
private PDPage resolveWidgetPage(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (widget == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -1445,7 +1962,7 @@ public class FormUtils {
|
||||
if (page != null) {
|
||||
return page;
|
||||
}
|
||||
int pageIndex = determineWidgetPageIndex(document, widget);
|
||||
int pageIndex = determineWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (pageIndex >= 0) {
|
||||
try {
|
||||
return document.getPage(pageIndex);
|
||||
@@ -1456,11 +1973,21 @@ public class FormUtils {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
||||
private int determineWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (document == null || widget == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
PDPage directPage = widget.getPage();
|
||||
if (directPage != null) {
|
||||
int index = 0;
|
||||
@@ -1488,6 +2015,33 @@ public class FormUtils {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of annotation COS dictionaries to their respective page index. Scan once
|
||||
* per-document to avoid O(N^2) lookups during field extraction.
|
||||
*/
|
||||
public Map<COSDictionary, Integer> buildAnnotationPageMap(PDDocument document) {
|
||||
if (document == null) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
Map<COSDictionary, Integer> map = new HashMap<>();
|
||||
int pageCount = document.getNumberOfPages();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try {
|
||||
PDPage page = document.getPage(i);
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null) {
|
||||
map.putIfAbsent(annot.getCOSObject(), i);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to index annotations for page {}: {}", i, e.getMessage());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<PDAnnotationWidget, Integer> buildWidgetPageFallbackMap(PDDocument document) {
|
||||
if (document == null) {
|
||||
return Collections.emptyMap();
|
||||
@@ -1760,4 +2314,46 @@ public class FormUtils {
|
||||
boolean multiSelect,
|
||||
String tooltip,
|
||||
int pageOrder) {}
|
||||
|
||||
/**
|
||||
* Comparator for sorting form fields by page, then vertically (top-to-bottom), then
|
||||
* horizontally (left-to-right) for fields on approximately the same line.
|
||||
*/
|
||||
static final class FieldCoordinateComparator implements Comparator<FormFieldWithCoordinates> {
|
||||
|
||||
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getPageIndex()
|
||||
: -1;
|
||||
}
|
||||
|
||||
private static float firstWidgetY(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getY()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static float firstWidgetX(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getX()
|
||||
: 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(FormFieldWithCoordinates a, FormFieldWithCoordinates b) {
|
||||
int pageA = firstWidgetPageIndex(a);
|
||||
int pageB = firstWidgetPageIndex(b);
|
||||
int pageCompare = Integer.compare(pageA, pageB);
|
||||
if (pageCompare != 0) return pageCompare;
|
||||
|
||||
float yA = firstWidgetY(a);
|
||||
float yB = firstWidgetY(b);
|
||||
|
||||
// Fields on approximately the same line should be sorted left-to-right
|
||||
if (Math.abs(yA - yB) < SAME_LINE_THRESHOLD_PT) {
|
||||
return Float.compare(firstWidgetX(a), firstWidgetX(b));
|
||||
}
|
||||
return Float.compare(yA, yB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -58,6 +58,7 @@ class AuthControllerLoginTest {
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
securityProperties.getJwt().setTokenExpiryMinutes(60);
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
@@ -175,7 +176,7 @@ class AuthControllerLoginTest {
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
|
||||
when(jwtService.extractUsernameAllowExpired("old")).thenReturn("user@example.com");
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
+3
-1
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
@@ -64,7 +65,8 @@ class JwtServiceTest {
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
||||
|
||||
jwtService = new JwtService(true, keystoreService);
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
jwtService = new JwtService(true, keystoreService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+4
-1
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -16,6 +17,8 @@ import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
class TotpServiceTest {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("[A-Z2-7]+");
|
||||
|
||||
private TotpService buildService(String appName) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
@@ -32,7 +35,7 @@ class TotpServiceTest {
|
||||
|
||||
assertNotNull(secret);
|
||||
assertEquals(32, secret.length());
|
||||
assertTrue(secret.matches("[A-Z2-7]+"));
|
||||
assertTrue(PATTERN.matcher(secret).matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.4.5'
|
||||
version = '2.5.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
Generated
+269
-177
@@ -10,29 +10,31 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^2.5.0",
|
||||
"@embedpdf/engines": "^2.5.0",
|
||||
"@embedpdf/models": "^2.5.0",
|
||||
"@embedpdf/plugin-annotation": "^2.5.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.5.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.5.0",
|
||||
"@embedpdf/plugin-export": "^2.5.0",
|
||||
"@embedpdf/plugin-history": "^2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.5.0",
|
||||
"@embedpdf/plugin-pan": "^2.5.0",
|
||||
"@embedpdf/plugin-print": "^2.5.0",
|
||||
"@embedpdf/plugin-redaction": "^2.5.0",
|
||||
"@embedpdf/plugin-render": "^2.5.0",
|
||||
"@embedpdf/plugin-rotate": "^2.5.0",
|
||||
"@embedpdf/plugin-scroll": "^2.5.0",
|
||||
"@embedpdf/plugin-search": "^2.5.0",
|
||||
"@embedpdf/plugin-selection": "^2.5.0",
|
||||
"@embedpdf/plugin-spread": "^2.5.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.5.0",
|
||||
"@embedpdf/plugin-tiling": "^2.5.0",
|
||||
"@embedpdf/plugin-viewport": "^2.5.0",
|
||||
"@embedpdf/plugin-zoom": "^2.5.0",
|
||||
"@embedpdf/core": "^2.6.0",
|
||||
"@embedpdf/engines": "^2.6.0",
|
||||
"@embedpdf/models": "^2.6.0",
|
||||
"@embedpdf/plugin-annotation": "^2.6.0",
|
||||
"@embedpdf/plugin-attachment": "^2.6.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.6.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-export": "^2.6.0",
|
||||
"@embedpdf/plugin-history": "^2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-pan": "^2.6.0",
|
||||
"@embedpdf/plugin-print": "^2.6.0",
|
||||
"@embedpdf/plugin-redaction": "^2.6.0",
|
||||
"@embedpdf/plugin-render": "^2.6.0",
|
||||
"@embedpdf/plugin-rotate": "^2.6.0",
|
||||
"@embedpdf/plugin-scroll": "^2.6.0",
|
||||
"@embedpdf/plugin-search": "^2.6.0",
|
||||
"@embedpdf/plugin-selection": "^2.6.0",
|
||||
"@embedpdf/plugin-spread": "^2.6.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.6.0",
|
||||
"@embedpdf/plugin-tiling": "^2.6.0",
|
||||
"@embedpdf/plugin-viewport": "^2.6.0",
|
||||
"@embedpdf/plugin-zoom": "^2.6.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -49,6 +51,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
@@ -59,7 +62,6 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
@@ -364,6 +366,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@cantoo/pdf-lib": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.5.3.tgz",
|
||||
"integrity": "sha512-SBQp8i/XdWNUhLutn5P67Pwj4X9vU046BRpfOMODJZuYVrgChtsTfgdnlW2O7x8gdXs8j7NoTaWI/b78E2oVmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"color": "^4.2.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"node-html-better-parser": ">=1.4.0",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": ">=2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@@ -553,13 +570,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-nI7GnA5xCNtJHAdKBLPKJVvi4+yAKjy1sysaDf+qp+z3D81Hy8oAcl///QTaZ9ob0SL2jyqi3x//hKl0Rwmgrw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.6.0.tgz",
|
||||
"integrity": "sha512-859GUvZ3BLpJuKTiwcPPMNn9CSlMaPjQ4yXnyQRngfbvDAiijIIpVLaC98B08Nx6QsUcD3cs/6+wkB888lNsDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "2.5.0",
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/engines": "2.6.0",
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -570,9 +587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.5.0.tgz",
|
||||
"integrity": "sha512-SEknNmQrYvkAZgJllRKXuvXSrHSndDQsr7b3mrIVa9bzV6TeZua0a/YUlvI3/jf74Sdajru3XKPe22iHEOH4Zg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.6.0.tgz",
|
||||
"integrity": "sha512-zW3927u0wbFBD2tQLWbE45DEBIMkZyN7n5O2p70er6u7mP1XYEz7Ud9NxcPL/3b5MzDfPBTSyxM3T12e+ZeAxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/fonts-arabic": "1.0.0",
|
||||
@@ -582,8 +599,8 @@
|
||||
"@embedpdf/fonts-latin": "1.0.0",
|
||||
"@embedpdf/fonts-sc": "1.0.0",
|
||||
"@embedpdf/fonts-tc": "1.0.0",
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/pdfium": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/pdfium": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -636,31 +653,48 @@
|
||||
"license": "OFL-1.1"
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.5.0.tgz",
|
||||
"integrity": "sha512-wu7XgargYBQEh46hVnfsmkTF6TvuoP9nAkTASR60s5ourjlT12qL9RiFLpwGkOBfs8E58h8V5hkgKsra5t03Lw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.6.0.tgz",
|
||||
"integrity": "sha512-6zuoJE79WXyRXKhJXhl+8p4njuC1nxPpKYRIs54PRLgTkHOLaou+G+ZunEd99XOoVssHLCjxWBUpg46ihQwXDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.5.0.tgz",
|
||||
"integrity": "sha512-2VEO4cNZsV8ig9upS+C+x3Tb58aqNxiAdaUMlD2ZZT8FgszhsV9xMyEuM2maFRdjeT7EO37FtzYBdXc/K67ivA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.6.0.tgz",
|
||||
"integrity": "sha512-eYXU1VvVI0e9OqOzvsTcsU6YSLq9F7jcAiIbtMB+NxApvvH3kHz3FPEcf8ha2ZiLftF5OAD8K89SSE5GLE6t1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.5.0.tgz",
|
||||
"integrity": "sha512-S5zCeWU3hM9jrnaGuW5RAXt+AzXXvQbFtAdCtxHW1hFADiZ97FKr8KS9MGCkkj6C9madtZP6iUJikvnhoLCABQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.6.0.tgz",
|
||||
"integrity": "sha512-FJgGy6lhKrWsiJjh7jZ92NwMBob5GOwfYejQl28JFk6muEQORLtysz5gaeyMpMIyxnfjlf9Eqv8Z6LBBfGLGOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/utils": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-history": "2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.5.0",
|
||||
"@embedpdf/plugin-selection": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-history": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-selection": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"svelte": ">=5 <6",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-attachment": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.6.0.tgz",
|
||||
"integrity": "sha512-6UZkj7jFWCruR69OPQFMqbJTgwdra4rnJSBfLA8yLxgz2zTsgt3owjfQDmlJvAQ7G1/rZM2T+EJeuozulj4NoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -669,15 +703,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-bookmark": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz",
|
||||
"integrity": "sha512-2N5kGoamUrQqWZC5SMWIhdyBHqZN/CdcGf8GVH71FFw3AU6rmZ1AD/AkLzgqoYGIuZFE8ACckdrhtbpsZMmSDQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.6.0.tgz",
|
||||
"integrity": "sha512-4JmaFD+gFaLj8Bayi6Fm5qxMoRH+JUy+L3S6xk1KM8YWjJyzsoz9C2mHSXKJ0GBMgiOkjaBuJSWqLgMe/oz7OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -686,15 +720,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-document-manager": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.5.0.tgz",
|
||||
"integrity": "sha512-I8Z/0B7R/YhtVaJFruwFO+QBLIDmQfHx9WVlrDXWZs68YiGwEbjSyizEIEqtulUJxcXfPs2Tf7oIBbdSuPG2NQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.6.0.tgz",
|
||||
"integrity": "sha512-fcx0JKDboEV8eQ4r++ksDHPDuUz40oOmtHDqxYLw6cpos0fqW0p55OP+fKp6LfC/bY7ULVDrmcEQf1cD9Qho4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -703,15 +737,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.5.0.tgz",
|
||||
"integrity": "sha512-KC9jXqwcxe76QqfxLx0tnrSdFoApTFOpT+dwrvox186uxYKSmSt1JHFWe4THB/A63hCNr8uMwyswYdFO8fWNHw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.6.0.tgz",
|
||||
"integrity": "sha512-i1Xy7qUipVVLDPnnY22hm3RNMx33lvuNbCuPggql5Ws6WBLG9YhDsK+v0JVe2sDSlifa5SJwuBlMHZWPRTZyxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -720,15 +754,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.5.0.tgz",
|
||||
"integrity": "sha512-Av9NBSE9Or1Y6cXcNWpx0bBZN3yI4vywa6kSNjhaqOrgpQDWMaTO57eApJpyHzBodqEztY+klE9YJ7MH88zm6w==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.6.0.tgz",
|
||||
"integrity": "sha512-cfVoBjkIbFiRsQu/cwPEi0rrTAF7jriAGzABWawnSTKYEPFrU3LDHO7TewgBz45kHl9pSwvRexaIdTR8ECIKbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -737,15 +771,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.5.0.tgz",
|
||||
"integrity": "sha512-QrmowLVvC5FNZdvVr2kczSDdnHHOuhf+So0VG5Ythts/OL1bIR/0OOpuyJsScTyo5boYnRkXv8yPf8htL57YKQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.6.0.tgz",
|
||||
"integrity": "sha512-9bruF6M6GKVdABRTinHsZ+izf2tDQwDEcNI0CHVc5gurrz3CQfAGP2sJkv8uQrXyYTK3zV2Oq6zGknk7Hdx9mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -754,17 +788,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.5.0.tgz",
|
||||
"integrity": "sha512-DfdA+hBm9kGYYy7OuJym6azk2h2U/Geirud+tmVzFSL7+OZ3tZ3K9fqj07w66zx0msyUVlYrXzkYSU9NEmwpLA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.6.0.tgz",
|
||||
"integrity": "sha512-r8AXcXUy6NMYDaQeixScbeFfmZIvWpUUjx3gxjP4J90xfxXnuz/g/lnh4D2DBaiK4mt6crIVBpXU9IUwMIcUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -773,15 +807,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-print": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.5.0.tgz",
|
||||
"integrity": "sha512-qejq7/0K9hh3hzop+u+Qmn7ijTqGcDhxaiXoPkyl91CZVOyAD8qMBzWnhC7vRNOB7hcYgBP81uegE3se+EIlcA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.6.0.tgz",
|
||||
"integrity": "sha512-cgWRqVtRgCCLCn1ViuZEFr+ZJ3QI61/5s9tl3T9x81rwkBN4HT582BYzyRnLBzTMYKxkQZDr1WxfS8ctdlHEUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
@@ -790,20 +824,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-redaction": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.5.0.tgz",
|
||||
"integrity": "sha512-G0cm1hLWi09gU8WV+IShq2XHkmLtEbk+EvD3dIiyJV2kbOjgwGSC2Ezt8br3DzH6R/0bF6RAbDIpFyS2Q0oMfg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.6.0.tgz",
|
||||
"integrity": "sha512-DdDnmOl9K0N4dpTeUohavxQyrfollhkjT+zdfkna3Fc7F4jfl3Vg6uKoGmT71A+Vp4uTGNLt6cNCscbJW9E9kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/utils": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-annotation": "2.5.0",
|
||||
"@embedpdf/plugin-history": "2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.5.0",
|
||||
"@embedpdf/plugin-selection": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-annotation": "2.6.0",
|
||||
"@embedpdf/plugin-history": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-selection": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -812,15 +846,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.5.0.tgz",
|
||||
"integrity": "sha512-nrTmg8cVMohcKYiQ/7erErsaWlyaq20OtXbVjmnPNnqz4amJLAjlPyudTJRlWWPyIiri9SF4A0ue5ICDY2sypg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.6.0.tgz",
|
||||
"integrity": "sha512-Rk4QCxDOzhQrvKPt/G3G+p5ELwnKFkC5ljHMd7ND23atR9E3wm5W3+Nx3FaAYYPrpfqQ7BrbKnfQ7SkUbDxS3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -829,15 +863,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.5.0.tgz",
|
||||
"integrity": "sha512-crFsXduaxNZJmVRfgklBpO4x4i9cRxPmfFBvdIoyJ1ea6AGOCL0rQKQcfqHTFdgtPzlVUiIg6Hi2v+033jdLUg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.6.0.tgz",
|
||||
"integrity": "sha512-zgF2S5cfkOxkOWrwoLQLN8scJgKBEhyhVOv/RNdeAKP6qE3h28AGRmDeMsekBDbiInlIxIHzynE5vVcTNf5EnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -846,16 +880,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.5.0.tgz",
|
||||
"integrity": "sha512-AdLuSgvAaukLl1uQ0FbswcAIPFaR3Jk2ZbEJpWLd9E6iQ+66Cta0Sz8d5J6ndx7VBlRYAZwoqiXF85utJxpQ5g==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.6.0.tgz",
|
||||
"integrity": "sha512-BEgSy6cs9+MLCS0Z3/FYMdA4Ygt6ddYIAg28XlF20kN3tLj8BQUo5qx6adI+SlwrFFGY52VAjjK7VSBuGfn19g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -864,15 +898,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.5.0.tgz",
|
||||
"integrity": "sha512-ycHJh05vBZ1PTSdEMgdx6K1py0oklwbwY2eXO4nD54EN9EVZgWlYC4Q+u8nyGOiNL6VmJYcqJ0HmjSWBdmGWBw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.6.0.tgz",
|
||||
"integrity": "sha512-GSzJkmuK9LE7LmlTwnDl71KdD9prHlCjgFs5Tm0K8qjELOSH+oduFXusIuf654+UQveDYczpzBVUcqb4yBf1xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -881,17 +915,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.5.0.tgz",
|
||||
"integrity": "sha512-M3WDjahig/6KE83SZGvTaJWhqEOIzH002k2fpJVuks926UBnfgYCH8uqV7SOUQTneQDmIa0PlyFiuEXDw1Ocrw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.6.0.tgz",
|
||||
"integrity": "sha512-VrW0duVxLwaquInwmuNDMz8o0tfCDwe3j81fvTUDW/s7KqnzFbxK7vEuq5TEtxWuSng2DXxyX3r1ntCm4X/NCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/utils": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -900,15 +934,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.5.0.tgz",
|
||||
"integrity": "sha512-kG8HZMZmbpUVDxCOEyQzIiMPW+VjjebOl93V+quAH+GAI5Tkg6exPyyQ2+/DOJPCtYX4Kh2z2aeoyK2b7NRgIQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.6.0.tgz",
|
||||
"integrity": "sha512-0mzPCJlw1X7jWeDg5JssU6/HCFtyOP7scEdbIaASYzofGXa2Rj8/+L+UDBrb+KTF6CR4X6fEfeNmxWmAatOAWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -917,16 +951,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.5.0.tgz",
|
||||
"integrity": "sha512-iWofJSXKbWrgvS2fe8v3U1+e2wjBRXD2i1DUcJKnTrqyfjZ8YzUomc5EzdG2RT7uUjtqrcu7463TZ9JHXUkASQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.6.0.tgz",
|
||||
"integrity": "sha512-Sj4jCV1MNk+19zKWX4KfSl5c0YHrqVG83pEYfqexjSkSX7y7HRwAOtMBtNd3uLInPPSBnzDxj+KlJlIe8RPPJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-render": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-render": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -935,18 +969,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.5.0.tgz",
|
||||
"integrity": "sha512-oih0GyGOJvfaXPLSEY+qfC05UUU1ZkADEbr6uCwRMmdHIXu/0ZTJnAToegfWXtfE+Sw0J5wscVkipXXIX5azlw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.6.0.tgz",
|
||||
"integrity": "sha512-qyiHWljryHWQ7uzip2WDg4x28o/1QM0wh9oIyz5WlBnrDaK6bLJGsWUym5P6WfLp0Y8h6GFAslNNcgjBv6E3qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-render": "2.5.0",
|
||||
"@embedpdf/plugin-scroll": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-render": "2.6.0",
|
||||
"@embedpdf/plugin-scroll": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -955,15 +989,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.5.0.tgz",
|
||||
"integrity": "sha512-z0AXHA9Z3rZdCLje7P2NsQbxKLJ4b/l8lgzXOVn5Ow/pIPE0D2P3fn9WzImHTNI1RNrZMdkW9OH3lfkEXFTqHw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.6.0.tgz",
|
||||
"integrity": "sha512-Ea7s+LivQ4ph01mVngU2tu2Ni/zulxzIyiifCpMaBMHmvjGFQjJcNNYHR90YuM8keto82KCszxdNDuAEEzT6Wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -972,17 +1006,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.5.0.tgz",
|
||||
"integrity": "sha512-HWJlqXOXdv/kttV+XWCCStUZAeLl66AuaO8BsnPlAPwEADLLCH4tR4XqJQoWr7/r5watKP7UeQ00FsWu0oGclw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.6.0.tgz",
|
||||
"integrity": "sha512-2XUgasN2ZQm2MgpB6ls/re/SKhsREvt2D1gIcvJgXvGkene0NcpxGNIRi/+JN7W0fw4x3QtwLQtyF+/0uMgPmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-scroll": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-scroll": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -991,9 +1025,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.5.0.tgz",
|
||||
"integrity": "sha512-JjYj6BRzu9oesA1JOqKPFMEWKinjvJIjziWu1j6lDXxLsE59bkShjUKbaEG+lkXRspuZRWNP++rzE2p2Ht4veg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.6.0.tgz",
|
||||
"integrity": "sha512-FT6U6L3Et688urUTyISpYH05w4sG+WzoWxaI7aPU4ieh4c/vVgadUtjZj/QCC8v+DebPYRAX1gpUY7e0Y0HlTQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -4180,6 +4214,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||
@@ -6452,11 +6495,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -6469,9 +6524,18 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -6590,6 +6654,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
@@ -8839,6 +8909,22 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
@@ -10685,6 +10771,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-html-better-parser": {
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
|
||||
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html-entities": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -11240,24 +11335,6 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.530",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
|
||||
@@ -12965,6 +13042,21 @@
|
||||
"integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle/node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
|
||||
+25
-23
@@ -7,28 +7,29 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^2.5.0",
|
||||
"@embedpdf/engines": "^2.5.0",
|
||||
"@embedpdf/models": "^2.5.0",
|
||||
"@embedpdf/plugin-annotation": "^2.5.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.5.0",
|
||||
"@embedpdf/plugin-export": "^2.5.0",
|
||||
"@embedpdf/plugin-history": "^2.5.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.5.0",
|
||||
"@embedpdf/plugin-pan": "^2.5.0",
|
||||
"@embedpdf/plugin-print": "^2.5.0",
|
||||
"@embedpdf/plugin-redaction": "^2.5.0",
|
||||
"@embedpdf/plugin-render": "^2.5.0",
|
||||
"@embedpdf/plugin-rotate": "^2.5.0",
|
||||
"@embedpdf/plugin-scroll": "^2.5.0",
|
||||
"@embedpdf/plugin-search": "^2.5.0",
|
||||
"@embedpdf/plugin-selection": "^2.5.0",
|
||||
"@embedpdf/plugin-spread": "^2.5.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.5.0",
|
||||
"@embedpdf/plugin-tiling": "^2.5.0",
|
||||
"@embedpdf/plugin-viewport": "^2.5.0",
|
||||
"@embedpdf/plugin-zoom": "^2.5.0",
|
||||
"@embedpdf/core": "^2.6.0",
|
||||
"@embedpdf/engines": "^2.6.0",
|
||||
"@embedpdf/models": "^2.6.0",
|
||||
"@embedpdf/plugin-annotation": "^2.6.0",
|
||||
"@embedpdf/plugin-attachment": "^2.6.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.6.0",
|
||||
"@embedpdf/plugin-export": "^2.6.0",
|
||||
"@embedpdf/plugin-history": "^2.6.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-pan": "^2.6.0",
|
||||
"@embedpdf/plugin-print": "^2.6.0",
|
||||
"@embedpdf/plugin-redaction": "^2.6.0",
|
||||
"@embedpdf/plugin-render": "^2.6.0",
|
||||
"@embedpdf/plugin-rotate": "^2.6.0",
|
||||
"@embedpdf/plugin-scroll": "^2.6.0",
|
||||
"@embedpdf/plugin-search": "^2.6.0",
|
||||
"@embedpdf/plugin-selection": "^2.6.0",
|
||||
"@embedpdf/plugin-spread": "^2.6.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.6.0",
|
||||
"@embedpdf/plugin-tiling": "^2.6.0",
|
||||
"@embedpdf/plugin-viewport": "^2.6.0",
|
||||
"@embedpdf/plugin-zoom": "^2.6.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -45,6 +46,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
@@ -55,7 +57,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+45
@@ -907,6 +907,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -3531,6 +3533,30 @@ dependencies = [
|
||||
"webpki-roots 1.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4229,6 +4255,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-log",
|
||||
@@ -4609,6 +4636,24 @@ dependencies = [
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
|
||||
@@ -28,6 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-shell = "2.3.4"
|
||||
tauri-plugin-fs = "2.4.5"
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-http = { version = "2.5.6", features = ["dangerous-settings"] }
|
||||
tauri-plugin-single-instance = { version = "2.3.7", features = ["deep-link"] }
|
||||
tauri-plugin-store = "2.4.2"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:allow-fetch",
|
||||
@@ -40,6 +41,18 @@
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-remove",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
"dialog:default",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
|
||||
@@ -33,3 +33,12 @@ pub async fn clear_opened_files() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Command to atomically get and clear opened file paths
|
||||
#[tauri::command]
|
||||
pub async fn pop_opened_files() -> Result<Vec<String>, String> {
|
||||
let mut opened_files = OPENED_FILES.lock().unwrap();
|
||||
let all_files = opened_files.clone();
|
||||
opened_files.clear();
|
||||
add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len()));
|
||||
Ok(all_files)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ pub mod auth;
|
||||
pub mod default_app;
|
||||
|
||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
is_first_launch,
|
||||
|
||||
@@ -16,6 +16,7 @@ use commands::{
|
||||
get_backend_port,
|
||||
get_connection_config,
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
get_refresh_token,
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
@@ -55,6 +56,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
@@ -139,6 +141,7 @@ pub fn run() {
|
||||
start_backend,
|
||||
get_backend_port,
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
clear_opened_files,
|
||||
get_tauri_logs,
|
||||
get_connection_config,
|
||||
@@ -170,9 +173,9 @@ pub fn run() {
|
||||
app_handle.cleanup_before_exit();
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
|
||||
add_log("🔄 Window close requested, cleaning up...".to_string());
|
||||
cleanup_backend();
|
||||
// Allow the window to close
|
||||
add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string());
|
||||
// Don't cleanup here - let JavaScript handler prevent close if needed
|
||||
// Backend cleanup happens in ExitRequested when window actually closes
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
|
||||
use tauri::DragDropEvent;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.4.5",
|
||||
"version": "2.5.0",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -13,8 +13,10 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 700,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
|
||||
|
||||
@@ -7,8 +7,8 @@ import { FilesModalProvider } from "@app/contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
|
||||
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions, useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
@@ -23,6 +23,7 @@ import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
@@ -69,6 +70,24 @@ export interface AppProvidersProps {
|
||||
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
|
||||
}
|
||||
|
||||
// Component to sync server defaults to preferences when AppConfig loads
|
||||
function ServerDefaultsSync() {
|
||||
const { config } = useAppConfig();
|
||||
const { updateServerDefaults } = usePreferences();
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
const serverDefaults = {
|
||||
hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
|
||||
hideUnavailableConversions: config.defaultHideUnavailableConversions ?? false,
|
||||
};
|
||||
updateServerDefaults(serverDefaults);
|
||||
}
|
||||
}, [config, updateServerDefaults]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core application providers
|
||||
* Contains all providers needed for the core
|
||||
@@ -85,6 +104,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<ServerDefaultsSync />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
@@ -98,6 +118,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
@@ -107,6 +128,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
@@ -33,9 +34,15 @@ const AddFileCard = ({
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
||||
const handleNativeUploadClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
const files = await openFilesFromDisk({
|
||||
multiple,
|
||||
onFallbackOpen: () => fileInputRef.current?.click()
|
||||
});
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||
@@ -179,4 +186,4 @@ const AddFileCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFileCard;
|
||||
export default AddFileCard;
|
||||
|
||||
@@ -12,7 +12,7 @@ import AddFileCard from '@app/components/fileEditor/AddFileCard';
|
||||
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
||||
import { FileId, StirlingFile } from '@app/types/fileContext';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { downloadBlob } from '@app/utils/downloadUtils';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
|
||||
@@ -278,13 +278,29 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleDownloadFile = useCallback((fileId: FileId) => {
|
||||
const handleDownloadFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: record.localFilePath
|
||||
});
|
||||
console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
|
||||
// Mark file as clean after successful save to disk
|
||||
if (result.savedPath) {
|
||||
console.log('[FileEditor] Marking file as clean:', fileId);
|
||||
fileActions.updateStirlingFileStub(fileId, {
|
||||
localFilePath: record.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
} else {
|
||||
console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
}, [activeStirlingFileStubs, selectors, fileActions]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
|
||||
interface FileEditorFileNameProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
);
|
||||
|
||||
export default FileEditorFileName;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user