mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
14
Commits
v2.7.1
...
pdfEditorFixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c6ea0f53 | ||
|
|
4418f562c7 | ||
|
|
4f62b00b3f | ||
|
|
c8967d80c0 | ||
|
|
9a282b1f74 | ||
|
|
bd65f36964 | ||
|
|
0b991c7de4 | ||
|
|
edb93c54b8 | ||
|
|
27f4e365d7 | ||
|
|
7fba215fab | ||
|
|
e04158eed6 | ||
|
|
93af41d74b | ||
|
|
c879e54441 | ||
|
|
c987deb31b |
@@ -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`)
|
||||
|
||||
+180
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -56,6 +57,7 @@ public class ConvertPdfJsonController {
|
||||
}
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
|
||||
logJsonResponse("pdf/text-editor", jsonBytes);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
@@ -112,6 +114,7 @@ public class ConvertPdfJsonController {
|
||||
|
||||
byte[] jsonBytes =
|
||||
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
|
||||
logJsonResponse("pdf/text-editor/metadata", jsonBytes);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
@@ -175,6 +178,7 @@ public class ConvertPdfJsonController {
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
|
||||
logJsonResponse("pdf/text-editor/page", jsonBytes);
|
||||
String docName = "page_" + pageNumber + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
@@ -209,6 +213,182 @@ public class ConvertPdfJsonController {
|
||||
return baseJobId;
|
||||
}
|
||||
|
||||
private void logJsonResponse(String label, byte[] jsonBytes) {
|
||||
if (jsonBytes == null) {
|
||||
log.warn("Returning {} JSON response: null bytes", label);
|
||||
return;
|
||||
}
|
||||
int length = jsonBytes.length;
|
||||
boolean endsWithJson =
|
||||
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
|
||||
String tail = "";
|
||||
if (length > 0) {
|
||||
int start = Math.max(0, length - 64);
|
||||
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
|
||||
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
|
||||
}
|
||||
log.info(
|
||||
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
|
||||
label,
|
||||
length,
|
||||
endsWithJson,
|
||||
tail);
|
||||
|
||||
if (isPdfJsonDebugDumpEnabled()) {
|
||||
try {
|
||||
String tmpDir = System.getProperty("java.io.tmpdir");
|
||||
String customDir = System.getenv("SPDF_PDFJSON_DUMP_DIR");
|
||||
java.nio.file.Path dumpDir =
|
||||
customDir != null && !customDir.isBlank()
|
||||
? java.nio.file.Path.of(customDir)
|
||||
: java.nio.file.Path.of(tmpDir);
|
||||
java.nio.file.Path dumpPath =
|
||||
java.nio.file.Files.createTempFile(dumpDir, "pdfjson_", ".json");
|
||||
java.nio.file.Files.write(dumpPath, jsonBytes);
|
||||
log.info("PDF JSON debug dump ({}): {}", label, dumpPath);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (isPdfJsonRepeatScanEnabled()) {
|
||||
logRepeatedJsonStrings(label, jsonBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPdfJsonDebugDumpEnabled() {
|
||||
String env = System.getenv("SPDF_PDFJSON_DUMP");
|
||||
if (env != null && env.equalsIgnoreCase("true")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.getBoolean("spdf.pdfjson.dump");
|
||||
}
|
||||
|
||||
private boolean isPdfJsonRepeatScanEnabled() {
|
||||
String env = System.getenv("SPDF_PDFJSON_REPEAT_SCAN");
|
||||
if (env != null && env.equalsIgnoreCase("true")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.getBoolean("spdf.pdfjson.repeatScan");
|
||||
}
|
||||
|
||||
private void logRepeatedJsonStrings(String label, byte[] jsonBytes) {
|
||||
final int minLen = 12;
|
||||
final int maxLen = 200;
|
||||
final int maxUnique = 50000;
|
||||
java.util.Map<String, Integer> counts = new java.util.HashMap<>();
|
||||
boolean inString = false;
|
||||
boolean escape = false;
|
||||
boolean tooLong = false;
|
||||
StringBuilder current = new StringBuilder(64);
|
||||
boolean capped = false;
|
||||
|
||||
for (byte b : jsonBytes) {
|
||||
char ch = (char) (b & 0xFF);
|
||||
if (!inString) {
|
||||
if (ch == '"') {
|
||||
inString = true;
|
||||
escape = false;
|
||||
tooLong = false;
|
||||
current.setLength(0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escape) {
|
||||
escape = false;
|
||||
if (!tooLong && current.length() < maxLen) {
|
||||
current.append(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '"') {
|
||||
inString = false;
|
||||
if (!tooLong) {
|
||||
int len = current.length();
|
||||
if (len >= minLen && len <= maxLen) {
|
||||
String value = current.toString();
|
||||
if (!looksLikeBase64(value)) {
|
||||
if (!capped || counts.containsKey(value)) {
|
||||
counts.merge(value, 1, Integer::sum);
|
||||
if (!capped && counts.size() >= maxUnique) {
|
||||
capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!tooLong) {
|
||||
if (current.length() < maxLen) {
|
||||
current.append(ch);
|
||||
} else {
|
||||
tooLong = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java.util.List<java.util.Map.Entry<String, Integer>> top =
|
||||
counts.entrySet().stream()
|
||||
.filter(e -> e.getValue() > 1)
|
||||
.sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
|
||||
.limit(20)
|
||||
.toList();
|
||||
|
||||
if (!top.isEmpty()) {
|
||||
String summary =
|
||||
top.stream()
|
||||
.map(
|
||||
e ->
|
||||
String.format(
|
||||
"\"%s\"(len=%d,count=%d)",
|
||||
truncateForLog(e.getKey()),
|
||||
e.getKey().length(),
|
||||
e.getValue()))
|
||||
.collect(java.util.stream.Collectors.joining("; "));
|
||||
log.info(
|
||||
"PDF JSON repeat scan ({}): top strings -> {}{}",
|
||||
label,
|
||||
summary,
|
||||
capped ? " (capped)" : "");
|
||||
} else {
|
||||
log.info(
|
||||
"PDF JSON repeat scan ({}): no repeated strings found{}", label, capped ? " (capped)" : "");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksLikeBase64(String value) {
|
||||
if (value.length() < 32) {
|
||||
return false;
|
||||
}
|
||||
int base64Chars = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if ((c >= 'A' && c <= 'Z')
|
||||
|| (c >= 'a' && c <= 'z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| c == '+'
|
||||
|| c == '/'
|
||||
|| c == '=') {
|
||||
base64Chars++;
|
||||
}
|
||||
}
|
||||
return base64Chars >= value.length() * 0.9;
|
||||
}
|
||||
|
||||
private String truncateForLog(String value) {
|
||||
int max = 64;
|
||||
if (value.length() <= max) {
|
||||
return value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
}
|
||||
return value.substring(0, max).replaceAll("[\\r\\n\\t]+", " ") + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
|
||||
+545
-34
@@ -427,8 +427,9 @@ public class PdfJsonConversionService {
|
||||
progress.accept(
|
||||
PdfJsonConversionProgress.of(
|
||||
80, "annotations", "Collecting annotations and form fields"));
|
||||
boolean includeAnnotationRawData = !(lightweight && isRealJobId);
|
||||
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage =
|
||||
collectAnnotations(document, totalPages, progress);
|
||||
collectAnnotations(document, totalPages, progress, includeAnnotationRawData);
|
||||
|
||||
progress.accept(
|
||||
PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata"));
|
||||
@@ -441,9 +442,15 @@ public class PdfJsonConversionService {
|
||||
Comparator.comparing(
|
||||
PdfJsonFont::getUid,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
dedupeFontPayloads(serializedFonts);
|
||||
pdfJson.setFonts(serializedFonts);
|
||||
pdfJson.setPages(
|
||||
extractPages(document, textByPage, imagesByPage, annotationsByPage));
|
||||
extractPages(
|
||||
document,
|
||||
textByPage,
|
||||
imagesByPage,
|
||||
annotationsByPage,
|
||||
lightweight && isRealJobId));
|
||||
pdfJson.setFormFields(collectFormFields(document));
|
||||
|
||||
// Only cache for real async jobIds, not synthetic synchronous ones
|
||||
@@ -507,6 +514,12 @@ public class PdfJsonConversionService {
|
||||
if (lightweight) {
|
||||
applyLightweightTransformations(pdfJson);
|
||||
}
|
||||
if (lightweight && isRealJobId) {
|
||||
stripFontCosStreamData(serializedFonts);
|
||||
}
|
||||
|
||||
logFontPayloadStats(serializedFonts, "pdf/text-editor");
|
||||
analyzePdfJson(pdfJson, "pdf/text-editor");
|
||||
|
||||
progress.accept(
|
||||
PdfJsonConversionProgress.of(95, "serializing", "Generating JSON output"));
|
||||
@@ -517,8 +530,9 @@ public class PdfJsonConversionService {
|
||||
.filter(
|
||||
f ->
|
||||
Boolean.TRUE.equals(f.getEmbedded())
|
||||
&& (f.getProgram() == null
|
||||
|| f.getProgram().isEmpty()))
|
||||
&& !(hasPayload(f.getProgram())
|
||||
|| hasPayload(f.getPdfProgram())
|
||||
|| hasPayload(f.getWebProgram())))
|
||||
.map(
|
||||
f -> {
|
||||
String name =
|
||||
@@ -1039,6 +1053,376 @@ public class PdfJsonConversionService {
|
||||
return toPdfJsonFont(cacheEntry, fontId, pageNumber, jobId);
|
||||
}
|
||||
|
||||
private void logFontPayloadStats(List<PdfJsonFont> fonts, String label) {
|
||||
if (fonts == null || fonts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long programBytes = 0;
|
||||
long webProgramBytes = 0;
|
||||
long pdfProgramBytes = 0;
|
||||
long toUnicodeBytes = 0;
|
||||
long maxFontPayload = 0;
|
||||
String maxFontId = null;
|
||||
|
||||
for (PdfJsonFont font : fonts) {
|
||||
if (font == null) {
|
||||
continue;
|
||||
}
|
||||
long fontBytes = 0;
|
||||
if (font.getProgram() != null) {
|
||||
long len = font.getProgram().length();
|
||||
programBytes += len;
|
||||
fontBytes += len;
|
||||
}
|
||||
if (font.getWebProgram() != null) {
|
||||
long len = font.getWebProgram().length();
|
||||
webProgramBytes += len;
|
||||
fontBytes += len;
|
||||
}
|
||||
if (font.getPdfProgram() != null) {
|
||||
long len = font.getPdfProgram().length();
|
||||
pdfProgramBytes += len;
|
||||
fontBytes += len;
|
||||
}
|
||||
if (font.getToUnicode() != null) {
|
||||
long len = font.getToUnicode().length();
|
||||
toUnicodeBytes += len;
|
||||
fontBytes += len;
|
||||
}
|
||||
if (fontBytes > maxFontPayload) {
|
||||
maxFontPayload = fontBytes;
|
||||
maxFontId = font.getUid() != null ? font.getUid() : font.getId();
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Font payload stats ({}): fonts={}, programBytes={}, webProgramBytes={}, pdfProgramBytes={}, toUnicodeBytes={}, maxFontPayloadBytes={} (fontId={})",
|
||||
label,
|
||||
fonts.size(),
|
||||
programBytes,
|
||||
webProgramBytes,
|
||||
pdfProgramBytes,
|
||||
toUnicodeBytes,
|
||||
maxFontPayload,
|
||||
maxFontId);
|
||||
}
|
||||
|
||||
private void analyzePdfJson(PdfJsonDocument pdfJson, String label) {
|
||||
if (!isPdfJsonDebugAnalyzeEnabled() || pdfJson == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, DuplicateStats> resourceStats = new HashMap<>();
|
||||
Map<String, DuplicateStats> fontDictStats = new HashMap<>();
|
||||
Map<String, DuplicateStats> annotationStats = new HashMap<>();
|
||||
long imageDataBytes = 0;
|
||||
long imageCount = 0;
|
||||
long textElementCount = 0;
|
||||
long textCharCount = 0;
|
||||
|
||||
List<PdfJsonPage> pages = pdfJson.getPages();
|
||||
if (pages != null) {
|
||||
for (PdfJsonPage page : pages) {
|
||||
if (page == null) {
|
||||
continue;
|
||||
}
|
||||
recordDuplicate(resourceStats, page.getResources());
|
||||
|
||||
List<PdfJsonAnnotation> annotations = page.getAnnotations();
|
||||
if (annotations != null) {
|
||||
for (PdfJsonAnnotation annotation : annotations) {
|
||||
recordDuplicate(annotationStats, annotation.getRawData());
|
||||
}
|
||||
}
|
||||
|
||||
List<PdfJsonImageElement> images = page.getImageElements();
|
||||
if (images != null) {
|
||||
for (PdfJsonImageElement image : images) {
|
||||
if (image == null) {
|
||||
continue;
|
||||
}
|
||||
String data = image.getImageData();
|
||||
if (data != null) {
|
||||
imageDataBytes += data.length();
|
||||
}
|
||||
imageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
List<PdfJsonTextElement> textElements = page.getTextElements();
|
||||
if (textElements != null) {
|
||||
for (PdfJsonTextElement element : textElements) {
|
||||
if (element == null) {
|
||||
continue;
|
||||
}
|
||||
textElementCount++;
|
||||
String text = element.getText();
|
||||
if (text != null) {
|
||||
textCharCount += text.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<PdfJsonFont> fonts = pdfJson.getFonts();
|
||||
if (fonts != null) {
|
||||
for (PdfJsonFont font : fonts) {
|
||||
recordDuplicate(fontDictStats, font.getCosDictionary());
|
||||
}
|
||||
}
|
||||
|
||||
logDuplicateSummary("resources", label, resourceStats);
|
||||
logDuplicateSummary("fontCosDictionary", label, fontDictStats);
|
||||
logDuplicateSummary("annotationRawData", label, annotationStats);
|
||||
log.info(
|
||||
"PDF JSON analysis ({}): images={} imageDataBytes={} textElements={} textChars={}",
|
||||
label,
|
||||
imageCount,
|
||||
imageDataBytes,
|
||||
textElementCount,
|
||||
textCharCount);
|
||||
|
||||
long fontsBytes = sizeOfObject(pdfJson.getFonts());
|
||||
long pagesBytes = sizeOfObject(pdfJson.getPages());
|
||||
long metadataBytes = sizeOfObject(pdfJson.getMetadata());
|
||||
long xmpBytes = sizeOfObject(pdfJson.getXmpMetadata());
|
||||
long formFieldsBytes = sizeOfObject(pdfJson.getFormFields());
|
||||
log.info(
|
||||
"PDF JSON analysis ({}): sectionSizes fonts={} pages={} metadata={} xmp={} formFields={}",
|
||||
label,
|
||||
fontsBytes,
|
||||
pagesBytes,
|
||||
metadataBytes,
|
||||
xmpBytes,
|
||||
formFieldsBytes);
|
||||
|
||||
if (pages != null && !pages.isEmpty()) {
|
||||
List<PageSizeStat> topPages = new ArrayList<>();
|
||||
int pageIndex = 0;
|
||||
for (PdfJsonPage page : pages) {
|
||||
if (page == null) {
|
||||
pageIndex++;
|
||||
continue;
|
||||
}
|
||||
long size = sizeOfObject(page);
|
||||
int pageNumber =
|
||||
page.getPageNumber() != null ? page.getPageNumber() : pageIndex + 1;
|
||||
topPages.add(new PageSizeStat(pageNumber, size, page));
|
||||
pageIndex++;
|
||||
}
|
||||
topPages.sort((a, b) -> Long.compare(b.sizeBytes, a.sizeBytes));
|
||||
String top =
|
||||
topPages.stream()
|
||||
.limit(5)
|
||||
.map(
|
||||
s ->
|
||||
String.format(
|
||||
"page=%d size=%d", s.pageNumber, s.sizeBytes))
|
||||
.collect(java.util.stream.Collectors.joining("; "));
|
||||
log.info("PDF JSON analysis ({}): topPageSizes -> {}", label, top);
|
||||
|
||||
topPages.stream()
|
||||
.limit(3)
|
||||
.forEach(
|
||||
s -> {
|
||||
PdfJsonPage page = s.page;
|
||||
long resources = sizeOfObject(page.getResources());
|
||||
long contentStreams = sizeOfObject(page.getContentStreams());
|
||||
long annotations = sizeOfObject(page.getAnnotations());
|
||||
long textElements = sizeOfObject(page.getTextElements());
|
||||
long imageElements = sizeOfObject(page.getImageElements());
|
||||
log.info(
|
||||
"PDF JSON analysis ({}): pageBreakdown page={} total={} resources={} contentStreams={} annotations={} textElements={} imageElements={}",
|
||||
label,
|
||||
s.pageNumber,
|
||||
s.sizeBytes,
|
||||
resources,
|
||||
contentStreams,
|
||||
annotations,
|
||||
textElements,
|
||||
imageElements);
|
||||
});
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("PDF JSON analysis failed ({}): {}", label, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void recordDuplicate(Map<String, DuplicateStats> stats, PdfJsonCosValue value)
|
||||
throws IOException, java.security.NoSuchAlgorithmException {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
byte[] bytes = objectMapper.writeValueAsBytes(value);
|
||||
if (bytes.length == 0) {
|
||||
return;
|
||||
}
|
||||
String hash = Base64.getEncoder().encodeToString(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
DuplicateStats entry = stats.computeIfAbsent(hash, k -> new DuplicateStats());
|
||||
entry.count++;
|
||||
if (entry.sizeBytes == 0) {
|
||||
entry.sizeBytes = bytes.length;
|
||||
}
|
||||
}
|
||||
|
||||
private void logDuplicateSummary(
|
||||
String category, String label, Map<String, DuplicateStats> stats) {
|
||||
if (stats.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<DuplicateStats> duplicates =
|
||||
stats.values().stream()
|
||||
.filter(s -> s.count > 1)
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
Long.compare(
|
||||
b.totalBytesSaved(), a.totalBytesSaved()))
|
||||
.limit(5)
|
||||
.toList();
|
||||
|
||||
if (duplicates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String summary =
|
||||
duplicates.stream()
|
||||
.map(
|
||||
s ->
|
||||
String.format(
|
||||
"count=%d size=%d potentialSavings=%d",
|
||||
s.count, s.sizeBytes, s.totalBytesSaved()))
|
||||
.collect(java.util.stream.Collectors.joining("; "));
|
||||
log.info(
|
||||
"PDF JSON analysis ({}): top duplicates for {} -> {}",
|
||||
label,
|
||||
category,
|
||||
summary);
|
||||
}
|
||||
|
||||
private boolean isPdfJsonDebugAnalyzeEnabled() {
|
||||
String env = System.getenv("SPDF_PDFJSON_ANALYZE");
|
||||
if (env != null && env.equalsIgnoreCase("true")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.getBoolean("spdf.pdfjson.analyze");
|
||||
}
|
||||
|
||||
private long sizeOfObject(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsBytes(value).length;
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to serialize object for size analysis: {}", ex.getMessage());
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DuplicateStats {
|
||||
private int count;
|
||||
private long sizeBytes;
|
||||
|
||||
private long totalBytesSaved() {
|
||||
return sizeBytes * (long) (count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PageSizeStat {
|
||||
private final int pageNumber;
|
||||
private final long sizeBytes;
|
||||
private final PdfJsonPage page;
|
||||
|
||||
private PageSizeStat(int pageNumber, long sizeBytes, PdfJsonPage page) {
|
||||
this.pageNumber = pageNumber;
|
||||
this.sizeBytes = sizeBytes;
|
||||
this.page = page;
|
||||
}
|
||||
}
|
||||
|
||||
private void dedupeFontPayloads(List<PdfJsonFont> fonts) {
|
||||
if (fonts == null || fonts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (PdfJsonFont font : fonts) {
|
||||
if (font == null) {
|
||||
continue;
|
||||
}
|
||||
String program = font.getProgram();
|
||||
String pdfProgram = font.getPdfProgram();
|
||||
String webProgram = font.getWebProgram();
|
||||
|
||||
if (pdfProgram != null && !pdfProgram.isBlank()) {
|
||||
if (program != null && program.equals(pdfProgram)) {
|
||||
font.setProgram(null);
|
||||
font.setProgramFormat(null);
|
||||
}
|
||||
if (webProgram != null && webProgram.equals(pdfProgram)) {
|
||||
font.setWebProgram(null);
|
||||
font.setWebProgramFormat(null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (program != null && webProgram != null && program.equals(webProgram)) {
|
||||
font.setWebProgram(null);
|
||||
font.setWebProgramFormat(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stripFontCosStreamData(List<PdfJsonFont> fonts) {
|
||||
if (fonts == null || fonts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<PdfJsonCosValue> visited =
|
||||
Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (PdfJsonFont font : fonts) {
|
||||
if (font == null) {
|
||||
continue;
|
||||
}
|
||||
PdfJsonCosValue cosDictionary = font.getCosDictionary();
|
||||
if (cosDictionary != null) {
|
||||
stripStreamRawData(cosDictionary, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stripStreamRawData(PdfJsonCosValue value, Set<PdfJsonCosValue> visited) {
|
||||
if (value == null || value.getType() == null) {
|
||||
return;
|
||||
}
|
||||
if (!visited.add(value)) {
|
||||
return;
|
||||
}
|
||||
switch (value.getType()) {
|
||||
case STREAM:
|
||||
if (value.getStream() != null) {
|
||||
value.getStream().setRawData(null);
|
||||
}
|
||||
break;
|
||||
case ARRAY:
|
||||
if (value.getItems() != null) {
|
||||
for (PdfJsonCosValue item : value.getItems()) {
|
||||
stripStreamRawData(item, visited);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case DICTIONARY:
|
||||
if (value.getEntries() != null) {
|
||||
for (PdfJsonCosValue entry : value.getEntries().values()) {
|
||||
stripStreamRawData(entry, visited);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private FontModelCacheEntry createFontCacheEntry(
|
||||
PDDocument document, PDFont font, String fontId, int pageNumber, String jobId)
|
||||
throws IOException {
|
||||
@@ -1975,7 +2359,8 @@ public class PdfJsonConversionService {
|
||||
PDDocument document,
|
||||
Map<Integer, List<PdfJsonTextElement>> textByPage,
|
||||
Map<Integer, List<PdfJsonImageElement>> imagesByPage,
|
||||
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage)
|
||||
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage,
|
||||
boolean omitResourceStreamData)
|
||||
throws IOException {
|
||||
List<PdfJsonPage> pages = new ArrayList<>();
|
||||
int pageIndex = 0;
|
||||
@@ -1998,8 +2383,14 @@ public class PdfJsonConversionService {
|
||||
// imageElements
|
||||
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
|
||||
COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase);
|
||||
pageModel.setResources(cosMapper.serializeCosValue(filteredResources));
|
||||
pageModel.setContentStreams(extractContentStreams(page));
|
||||
PdfJsonCosValue resourcesModel =
|
||||
omitResourceStreamData
|
||||
? cosMapper.serializeCosValue(
|
||||
filteredResources,
|
||||
PdfJsonCosMapper.SerializationContext.RESOURCES_LIGHTWEIGHT)
|
||||
: cosMapper.serializeCosValue(filteredResources);
|
||||
pageModel.setResources(resourcesModel);
|
||||
pageModel.setContentStreams(extractContentStreams(page, true));
|
||||
pages.add(pageModel);
|
||||
pageIndex++;
|
||||
}
|
||||
@@ -2030,7 +2421,10 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
private Map<Integer, List<PdfJsonAnnotation>> collectAnnotations(
|
||||
PDDocument document, int totalPages, Consumer<PdfJsonConversionProgress> progress)
|
||||
PDDocument document,
|
||||
int totalPages,
|
||||
Consumer<PdfJsonConversionProgress> progress,
|
||||
boolean includeRawData)
|
||||
throws IOException {
|
||||
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage = new LinkedHashMap<>();
|
||||
int pageNumber = 1;
|
||||
@@ -2102,8 +2496,13 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
// Store raw dictionary for lossless round-trip
|
||||
ann.setRawData(cosMapper.serializeCosValue(annotDict));
|
||||
if (includeRawData) {
|
||||
// Store raw dictionary for lossless round-trip
|
||||
ann.setRawData(
|
||||
cosMapper.serializeCosValue(
|
||||
annotDict,
|
||||
PdfJsonCosMapper.SerializationContext.ANNOTATION_RAW_DATA));
|
||||
}
|
||||
|
||||
annotations.add(ann);
|
||||
} catch (Exception e) {
|
||||
@@ -2183,7 +2582,10 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
// Store raw dictionary for lossless round-trip
|
||||
formField.setRawData(cosMapper.serializeCosValue(field.getCOSObject()));
|
||||
formField.setRawData(
|
||||
cosMapper.serializeCosValue(
|
||||
field.getCOSObject(),
|
||||
PdfJsonCosMapper.SerializationContext.FORM_FIELD_RAW_DATA));
|
||||
|
||||
formFields.add(formField);
|
||||
} catch (Exception e) {
|
||||
@@ -2517,7 +2919,8 @@ public class PdfJsonConversionService {
|
||||
return streams;
|
||||
}
|
||||
|
||||
private List<PdfJsonStream> extractContentStreams(PDPage page) throws IOException {
|
||||
private List<PdfJsonStream> extractContentStreams(PDPage page, boolean omitRawData)
|
||||
throws IOException {
|
||||
List<PdfJsonStream> streams = new ArrayList<>();
|
||||
Iterator<PDStream> iterator = page.getContentStreams();
|
||||
if (iterator == null) {
|
||||
@@ -2525,7 +2928,13 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
while (iterator.hasNext()) {
|
||||
PDStream stream = iterator.next();
|
||||
PdfJsonStream model = cosMapper.serializeStream(stream);
|
||||
PdfJsonStream model =
|
||||
omitRawData
|
||||
? cosMapper.serializeStream(
|
||||
stream,
|
||||
PdfJsonCosMapper.SerializationContext
|
||||
.CONTENT_STREAMS_LIGHTWEIGHT)
|
||||
: cosMapper.serializeStream(stream);
|
||||
if (model != null) {
|
||||
streams.add(model);
|
||||
}
|
||||
@@ -2533,6 +2942,10 @@ public class PdfJsonConversionService {
|
||||
return streams;
|
||||
}
|
||||
|
||||
private List<PdfJsonStream> extractContentStreams(PDPage page) throws IOException {
|
||||
return extractContentStreams(page, false);
|
||||
}
|
||||
|
||||
private PDStream extractVectorGraphics(
|
||||
PDDocument document,
|
||||
List<PDStream> preservedStreams,
|
||||
@@ -5651,6 +6064,8 @@ public class PdfJsonConversionService {
|
||||
serializedFonts.sort(
|
||||
Comparator.comparing(
|
||||
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
dedupeFontPayloads(serializedFonts);
|
||||
stripFontCosStreamData(serializedFonts);
|
||||
docMetadata.setFonts(serializedFonts);
|
||||
|
||||
// Extract page dimensions
|
||||
@@ -5816,7 +6231,7 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
ann.setRawData(cosMapper.serializeCosValue(annotDict));
|
||||
// For cached page extraction, skip rawData to avoid huge payloads
|
||||
annotations.add(ann);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
@@ -5839,8 +6254,11 @@ public class PdfJsonConversionService {
|
||||
// Extract resources and content streams
|
||||
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
|
||||
COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase);
|
||||
pageModel.setResources(cosMapper.serializeCosValue(filteredResources));
|
||||
pageModel.setContentStreams(extractContentStreams(page));
|
||||
pageModel.setResources(
|
||||
cosMapper.serializeCosValue(
|
||||
filteredResources,
|
||||
PdfJsonCosMapper.SerializationContext.RESOURCES_LIGHTWEIGHT));
|
||||
pageModel.setContentStreams(extractContentStreams(page, true));
|
||||
|
||||
log.debug(
|
||||
"Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}",
|
||||
@@ -6045,6 +6463,12 @@ public class PdfJsonConversionService {
|
||||
List<PdfJsonFont> fontModels,
|
||||
int pageNumberValue)
|
||||
throws IOException {
|
||||
boolean preserveExistingAnnotations =
|
||||
shouldPreserveExistingAnnotations(pageModel.getAnnotations());
|
||||
boolean preserveExistingContentStreams =
|
||||
shouldPreserveExistingContentStreams(pageModel.getContentStreams());
|
||||
boolean preserveExistingResources = shouldPreserveExistingResources(pageModel.getResources());
|
||||
|
||||
PDRectangle currentBox = page.getMediaBox();
|
||||
float fallbackWidth = currentBox != null ? currentBox.getWidth() : 612f;
|
||||
float fallbackHeight = currentBox != null ? currentBox.getHeight() : 792f;
|
||||
@@ -6059,14 +6483,20 @@ public class PdfJsonConversionService {
|
||||
page.setRotation(pageModel.getRotation());
|
||||
}
|
||||
|
||||
applyPageResources(document, page, pageModel.getResources());
|
||||
if (!preserveExistingResources) {
|
||||
applyPageResources(document, page, pageModel.getResources());
|
||||
}
|
||||
|
||||
List<PDStream> preservedStreams =
|
||||
buildContentStreams(document, pageModel.getContentStreams());
|
||||
if (preservedStreams.isEmpty()) {
|
||||
page.setContents(new ArrayList<>());
|
||||
List<PDStream> preservedStreams;
|
||||
if (preserveExistingContentStreams) {
|
||||
preservedStreams = snapshotExistingContentStreams(page);
|
||||
} else {
|
||||
page.setContents(preservedStreams);
|
||||
preservedStreams = buildContentStreams(document, pageModel.getContentStreams());
|
||||
if (preservedStreams.isEmpty()) {
|
||||
page.setContents(new ArrayList<>());
|
||||
} else {
|
||||
page.setContents(preservedStreams);
|
||||
}
|
||||
}
|
||||
|
||||
List<PdfJsonImageElement> imageElements =
|
||||
@@ -6106,12 +6536,14 @@ public class PdfJsonConversionService {
|
||||
pageNumberValue);
|
||||
|
||||
if (regenerateMode == RegenerateMode.REUSE_EXISTING) {
|
||||
page.getAnnotations().clear();
|
||||
List<PdfJsonAnnotation> annotations =
|
||||
pageModel.getAnnotations() != null
|
||||
? new ArrayList<>(pageModel.getAnnotations())
|
||||
: new ArrayList<>();
|
||||
restoreAnnotations(document, page, annotations);
|
||||
if (!preserveExistingAnnotations) {
|
||||
page.getAnnotations().clear();
|
||||
List<PdfJsonAnnotation> annotations =
|
||||
pageModel.getAnnotations() != null
|
||||
? new ArrayList<>(pageModel.getAnnotations())
|
||||
: new ArrayList<>();
|
||||
restoreAnnotations(document, page, annotations);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6140,12 +6572,14 @@ public class PdfJsonConversionService {
|
||||
pageNumberValue,
|
||||
appendMode);
|
||||
|
||||
page.getAnnotations().clear();
|
||||
List<PdfJsonAnnotation> annotations =
|
||||
pageModel.getAnnotations() != null
|
||||
? new ArrayList<>(pageModel.getAnnotations())
|
||||
: new ArrayList<>();
|
||||
restoreAnnotations(document, page, annotations);
|
||||
if (!preserveExistingAnnotations) {
|
||||
page.getAnnotations().clear();
|
||||
List<PdfJsonAnnotation> annotations =
|
||||
pageModel.getAnnotations() != null
|
||||
? new ArrayList<>(pageModel.getAnnotations())
|
||||
: new ArrayList<>();
|
||||
restoreAnnotations(document, page, annotations);
|
||||
}
|
||||
}
|
||||
|
||||
private RegenerateMode determineRegenerateMode(
|
||||
@@ -6192,6 +6626,83 @@ public class PdfJsonConversionService {
|
||||
REGENERATE_CLEAR
|
||||
}
|
||||
|
||||
private boolean shouldPreserveExistingAnnotations(List<PdfJsonAnnotation> annotations) {
|
||||
if (annotations == null || annotations.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (PdfJsonAnnotation annotation : annotations) {
|
||||
if (annotation == null || annotation.getRawData() == null) {
|
||||
return true;
|
||||
}
|
||||
if (hasMissingStreamData(annotation.getRawData())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean shouldPreserveExistingContentStreams(List<PdfJsonStream> streams) {
|
||||
if (streams == null || streams.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (PdfJsonStream stream : streams) {
|
||||
if (stream == null || stream.getRawData() == null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean shouldPreserveExistingResources(PdfJsonCosValue resources) {
|
||||
return hasMissingStreamData(resources);
|
||||
}
|
||||
|
||||
private List<PDStream> snapshotExistingContentStreams(PDPage page) throws IOException {
|
||||
List<PDStream> streams = new ArrayList<>();
|
||||
Iterator<PDStream> iterator = page.getContentStreams();
|
||||
if (iterator == null) {
|
||||
return streams;
|
||||
}
|
||||
while (iterator.hasNext()) {
|
||||
PDStream stream = iterator.next();
|
||||
if (stream != null) {
|
||||
streams.add(stream);
|
||||
}
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
|
||||
private boolean hasMissingStreamData(PdfJsonCosValue value) {
|
||||
if (value == null || value.getType() == null) {
|
||||
return false;
|
||||
}
|
||||
switch (value.getType()) {
|
||||
case STREAM:
|
||||
PdfJsonStream stream = value.getStream();
|
||||
return stream == null || stream.getRawData() == null;
|
||||
case ARRAY:
|
||||
if (value.getItems() != null) {
|
||||
for (PdfJsonCosValue item : value.getItems()) {
|
||||
if (hasMissingStreamData(item)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
case DICTIONARY:
|
||||
if (value.getEntries() != null) {
|
||||
for (PdfJsonCosValue entry : value.getEntries().values()) {
|
||||
if (hasMissingStreamData(entry)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedules automatic cleanup of cached documents after 30 minutes. */
|
||||
private void scheduleDocumentCleanup(String jobId) {
|
||||
new Thread(
|
||||
|
||||
@@ -37,23 +37,70 @@ import stirling.software.SPDF.model.json.PdfJsonStream;
|
||||
@Component
|
||||
public class PdfJsonCosMapper {
|
||||
|
||||
public enum SerializationContext {
|
||||
DEFAULT,
|
||||
ANNOTATION_RAW_DATA,
|
||||
FORM_FIELD_RAW_DATA,
|
||||
CONTENT_STREAMS_LIGHTWEIGHT,
|
||||
RESOURCES_LIGHTWEIGHT;
|
||||
|
||||
public boolean omitStreamData() {
|
||||
return this != DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
|
||||
if (stream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(
|
||||
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
stream.getCOSObject(),
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
return serializeStream(
|
||||
cosStream,
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream, SerializationContext context)
|
||||
throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
SerializationContext effective =
|
||||
context != null ? context : SerializationContext.DEFAULT;
|
||||
return serializeStream(
|
||||
cosStream, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(PDStream stream, SerializationContext context)
|
||||
throws IOException {
|
||||
if (stream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(stream.getCOSObject(), context);
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
|
||||
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
return serializeCosValue(
|
||||
base,
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base, SerializationContext context)
|
||||
throws IOException {
|
||||
SerializationContext effective =
|
||||
context != null ? context : SerializationContext.DEFAULT;
|
||||
return serializeCosValue(
|
||||
base, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
|
||||
}
|
||||
|
||||
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
|
||||
@@ -165,8 +212,8 @@ public class PdfJsonCosMapper {
|
||||
return cosStream;
|
||||
}
|
||||
|
||||
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
|
||||
throws IOException {
|
||||
private PdfJsonCosValue serializeCosValue(
|
||||
COSBase base, Set<COSBase> visited, SerializationContext context) throws IOException {
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -220,21 +267,22 @@ public class PdfJsonCosMapper {
|
||||
if (base instanceof COSArray array) {
|
||||
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
|
||||
for (COSBase item : array) {
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited, context);
|
||||
items.add(serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSStream stream) {
|
||||
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
|
||||
builder.type(PdfJsonCosValue.Type.STREAM)
|
||||
.stream(serializeStream(stream, visited, context));
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSDictionary dictionary) {
|
||||
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
|
||||
for (COSName key : dictionary.keySet()) {
|
||||
PdfJsonCosValue serialized =
|
||||
serializeCosValue(dictionary.getDictionaryObject(key), visited);
|
||||
serializeCosValue(dictionary.getDictionaryObject(key), visited, context);
|
||||
entries.put(key.getName(), serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
|
||||
@@ -248,16 +296,23 @@ public class PdfJsonCosMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
|
||||
private PdfJsonStream serializeStream(
|
||||
COSStream cosStream, Set<COSBase> visited, SerializationContext context)
|
||||
throws IOException {
|
||||
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
|
||||
for (COSName key : cosStream.keySet()) {
|
||||
COSBase value = cosStream.getDictionaryObject(key);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited, context);
|
||||
if (serialized != null) {
|
||||
dictionary.put(key.getName(), serialized);
|
||||
}
|
||||
}
|
||||
|
||||
if (context != null && context.omitStreamData()) {
|
||||
log.debug("Omitting stream rawData during {} serialization", context);
|
||||
return PdfJsonStream.builder().dictionary(dictionary).rawData(null).build();
|
||||
}
|
||||
|
||||
String rawData = null;
|
||||
try (InputStream inputStream = cosStream.createRawInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
@@ -32,4 +32,7 @@ services:
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SHOW_SURVEY: "true"
|
||||
SPDF_PDFJSON_DUMP: "true"
|
||||
SPDF_PDFJSON_ANALYZE: "true"
|
||||
SPDF_PDFJSON_REPEAT_SCAN: "true"
|
||||
restart: on-failure:5
|
||||
|
||||
Generated
+10
@@ -49,6 +49,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",
|
||||
@@ -4180,6 +4181,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",
|
||||
|
||||
@@ -45,6 +45,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",
|
||||
|
||||
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"
|
||||
|
||||
@@ -40,6 +40,17 @@
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-remove",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
|
||||
@@ -55,6 +55,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())
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { documentManipulationService } from "@app/services/documentManipulationService";
|
||||
import { pdfExportService } from "@app/services/pdfExportService";
|
||||
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
|
||||
import { saveToLocalPath, saveMultipleFilesWithPrompt } from "@app/services/localFileSaveService";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
|
||||
@@ -295,6 +296,48 @@ export const usePageEditorExport = ({
|
||||
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
|
||||
}
|
||||
|
||||
// Auto-save to local path if single source had one (desktop only)
|
||||
if (sourceFileIds.length === 1 && newStirlingFiles.length === 1) {
|
||||
const sourceStub = selectors.getStirlingFileStub(sourceFileIds[0]);
|
||||
if (sourceStub?.localFilePath && renamedFiles[0]) {
|
||||
const result = await saveToLocalPath(renamedFiles[0], sourceStub.localFilePath);
|
||||
if (result.success) {
|
||||
console.log(`[PageEditor] Auto-saved to ${sourceStub.localFilePath}`);
|
||||
// Preserve localFilePath in output file
|
||||
actions.updateStirlingFileStub(newStirlingFiles[0].fileId, {
|
||||
localFilePath: sourceStub.localFilePath
|
||||
});
|
||||
} else if (result.error && !result.error.includes('not available in web mode')) {
|
||||
console.warn('[PageEditor] Auto-save failed:', result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for folder if single source with local path produced multiple outputs (desktop only)
|
||||
if (sourceFileIds.length === 1 && newStirlingFiles.length > 1) {
|
||||
const sourceStub = selectors.getStirlingFileStub(sourceFileIds[0]);
|
||||
if (sourceStub?.localFilePath && renamedFiles.length > 0) {
|
||||
// Get directory of original file as default
|
||||
const { dirname } = await import("@tauri-apps/api/path");
|
||||
const defaultDir = await dirname(sourceStub.localFilePath);
|
||||
|
||||
const result = await saveMultipleFilesWithPrompt(renamedFiles, defaultDir);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[PageEditor] Saved ${result.savedCount} files to user-selected folder`);
|
||||
} else if (result.cancelledByUser) {
|
||||
console.log('[PageEditor] User cancelled save dialog - files remain in workbench');
|
||||
} else if (result.error && !result.error.includes('not available in web mode')) {
|
||||
console.warn('[PageEditor] Multi-file save failed:', result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove source files from context
|
||||
if (sourceFileIds.length > 0) {
|
||||
await actions.removeFiles(sourceFileIds, true);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setSplitPositions(new Set());
|
||||
setExportLoading(false);
|
||||
|
||||
@@ -22,6 +22,7 @@ import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
import { showSaveDialog, saveToLocalPath } from '@app/services/localFileSaveService';
|
||||
|
||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||
|
||||
@@ -141,7 +142,7 @@ export default function RightRail() {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
viewerContext?.exportActions?.download();
|
||||
viewerContext?.exportActions?.download?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,34 +151,55 @@ export default function RightRail() {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
// Try desktop "Save As" dialog first (will be no-op in web mode)
|
||||
let usedDesktopSave = false;
|
||||
for (const file of filesToExport) {
|
||||
const savePath = await showSaveDialog(file.name);
|
||||
if (savePath) {
|
||||
usedDesktopSave = true;
|
||||
const result = await saveToLocalPath(file, savePath);
|
||||
if (result.success) {
|
||||
console.log(`[RightRail] Saved to: ${savePath}`);
|
||||
} else if (result.error && !result.error.includes('not available in web mode')) {
|
||||
console.error(`[RightRail] Failed to save: ${result.error}`);
|
||||
alert(`Failed to save ${file.name}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to browser download if desktop save wasn't used
|
||||
if (!usedDesktopSave) {
|
||||
filesToExport.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
currentView,
|
||||
selectedFiles,
|
||||
activeFiles,
|
||||
pageEditorFunctions,
|
||||
viewerContext,
|
||||
signaturesApplied
|
||||
signaturesApplied,
|
||||
]);
|
||||
|
||||
const downloadTooltip = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
return t('rightRail.exportAll', 'Export PDF');
|
||||
}
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return terminology.downloadSelected;
|
||||
}
|
||||
return terminology.downloadAll;
|
||||
}, [currentView, selectedCount, t]);
|
||||
}, [currentView, selectedCount, t, terminology]);
|
||||
|
||||
return (
|
||||
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
|
||||
|
||||
@@ -329,6 +329,7 @@ function FileContextInner({
|
||||
addStirlingFileStubs: addStirlingFileStubsAction,
|
||||
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
||||
// Remove from memory and cleanup resources
|
||||
// Note: Files with localFilePath are kept on disk - only removed from app
|
||||
lifecycleManager.removeFiles(fileIds, stateRef);
|
||||
|
||||
// Remove from IndexedDB if enabled
|
||||
|
||||
@@ -5,6 +5,11 @@ import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { downloadFiles } from '@app/utils/downloadUtils';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils';
|
||||
import { openFileDialog } from '@app/services/fileDialogService';
|
||||
|
||||
// Module-level storage for file path mappings (quickKey -> localFilePath)
|
||||
// Used to pass file paths from Tauri file dialog to FileContext
|
||||
export const pendingFilePathMappings = new Map<string, string>();
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
@@ -135,9 +140,40 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocalFileClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
const handleLocalFileClick = useCallback(async () => {
|
||||
console.log('[FileManager] Opening file dialog...');
|
||||
|
||||
// Try native dialog first (desktop), falls back to empty array (web)
|
||||
const filesWithPaths = await openFileDialog({
|
||||
multiple: true,
|
||||
filters: [{
|
||||
name: 'Documents',
|
||||
extensions: ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'tiff', 'bmp', 'html', 'zip']
|
||||
}]
|
||||
});
|
||||
|
||||
if (filesWithPaths.length > 0) {
|
||||
// Desktop mode: files selected through native dialog
|
||||
console.log('[FileManager] Storing file path mappings:');
|
||||
for (const { quickKey, path } of filesWithPaths) {
|
||||
console.log(` - ${quickKey} -> ${path}`);
|
||||
pendingFilePathMappings.set(quickKey, path);
|
||||
}
|
||||
console.log('[FileManager] Total pending mappings:', pendingFilePathMappings.size);
|
||||
|
||||
// Pass files to FileContext
|
||||
const files = filesWithPaths.map(f => f.file);
|
||||
console.log('[FileManager] Passing files to FileContext:', files.map(f => f.name));
|
||||
onNewFilesSelect(files);
|
||||
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} else {
|
||||
// Web mode: use browser file input (no native dialog)
|
||||
console.log('[FileManager] Using browser file input');
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||
|
||||
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
|
||||
@@ -311,6 +311,25 @@ export async function addFiles(
|
||||
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
|
||||
const fileStub = createNewStirlingFileStub(file, fileId);
|
||||
|
||||
// Check for pending file path mapping from Tauri file dialog (desktop only)
|
||||
try {
|
||||
const { pendingFilePathMappings } = await import('@app/contexts/FileManagerContext');
|
||||
console.log(`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`);
|
||||
console.log(`[FileActions] Available mappings:`, Array.from(pendingFilePathMappings.keys()));
|
||||
const localFilePath = pendingFilePathMappings.get(quickKey);
|
||||
if (localFilePath) {
|
||||
console.log(`[FileActions] ✓ Found localFilePath: ${localFilePath}`);
|
||||
fileStub.localFilePath = localFilePath;
|
||||
pendingFilePathMappings.delete(quickKey); // Clean up after use
|
||||
console.log(`[FileActions] Applied localFilePath to file: ${file.name}`);
|
||||
} else {
|
||||
console.log(`[FileActions] ✗ No localFilePath found for this file`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[FileActions] Could not check for localFilePath:', error);
|
||||
// FileManagerContext may not be available in all contexts
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
fileStub.insertAfterPageId = options.insertAfterPageId;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/fi
|
||||
import { ToolOperation } from '@app/types/file';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
|
||||
import { saveToLocalPath, saveMultipleFilesWithPrompt } from '@app/services/localFileSaveService';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ProcessingProgress, ResponseHandler };
|
||||
@@ -437,6 +438,61 @@ export const useToolOperation = <TParams>(
|
||||
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
||||
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
|
||||
// Auto-save to local path if single input with local path produced single output (desktop only)
|
||||
console.log('[Tool] Checking auto-save conditions:', {
|
||||
inputCount: toConsumeInputIds.length,
|
||||
outputCount: outputStirlingFiles.length
|
||||
});
|
||||
if (toConsumeInputIds.length === 1 && outputStirlingFiles.length === 1) {
|
||||
const inputStub = selectors.getStirlingFileStub(toConsumeInputIds[0]);
|
||||
console.log('[Tool] Input file stub:', inputStub);
|
||||
console.log('[Tool] Has localFilePath?', !!inputStub?.localFilePath, inputStub?.localFilePath);
|
||||
if (inputStub?.localFilePath) {
|
||||
console.log('[Tool] Attempting auto-save to:', inputStub.localFilePath);
|
||||
const result = await saveToLocalPath(outputStirlingFiles[0], inputStub.localFilePath);
|
||||
if (result.success) {
|
||||
console.log(`[Tool] ✓ Auto-saved to ${inputStub.localFilePath}`);
|
||||
// Preserve localFilePath in output file for future operations
|
||||
fileActions.updateStirlingFileStub(outputFileIds[0], {
|
||||
localFilePath: inputStub.localFilePath
|
||||
});
|
||||
} else if (result.error && !result.error.includes('not available in web mode')) {
|
||||
console.warn('[Tool] ✗ Auto-save failed:', result.error);
|
||||
}
|
||||
} else {
|
||||
console.log('[Tool] No localFilePath on input file - skipping auto-save');
|
||||
}
|
||||
} else {
|
||||
console.log('[Tool] Auto-save conditions not met - skipping');
|
||||
}
|
||||
|
||||
// Prompt for folder if single input with local path produced multiple outputs (desktop only)
|
||||
if (toConsumeInputIds.length === 1 && outputStirlingFiles.length > 1) {
|
||||
const inputStub = selectors.getStirlingFileStub(toConsumeInputIds[0]);
|
||||
if (inputStub?.localFilePath) {
|
||||
// Get directory of original file as default
|
||||
const { dirname } = await import('@tauri-apps/api/path');
|
||||
const defaultDir = await dirname(inputStub.localFilePath);
|
||||
|
||||
actions.setStatus(`Saving ${outputStirlingFiles.length} files...`);
|
||||
const result = await saveMultipleFilesWithPrompt(outputStirlingFiles, defaultDir);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[Tool] Saved ${result.savedCount} files to user-selected folder`);
|
||||
actions.setStatus(`Saved ${result.savedCount} file${result.savedCount > 1 ? 's' : ''}`);
|
||||
} else if (result.cancelledByUser) {
|
||||
console.log('[Tool] User cancelled save dialog - files remain in workbench');
|
||||
actions.setStatus('Files added to workbench');
|
||||
} else if (result.error && !result.error.includes('not available in web mode')) {
|
||||
console.warn('[Tool] Multi-file save failed:', result.error);
|
||||
actions.setStatus(result.savedCount > 0
|
||||
? `Saved ${result.savedCount}/${outputStirlingFiles.length} files`
|
||||
: 'Save failed - files remain in workbench'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
|
||||
@@ -532,6 +588,18 @@ export const useToolOperation = <TParams>(
|
||||
// Undo the consume operation
|
||||
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
|
||||
|
||||
// Auto-restore original file to local path if applicable (desktop only)
|
||||
if (inputStirlingFileStubs.length === 1 && inputFiles[0]) {
|
||||
const inputStub = inputStirlingFileStubs[0];
|
||||
if (inputStub?.localFilePath) {
|
||||
const result = await saveToLocalPath(inputFiles[0], inputStub.localFilePath);
|
||||
if (result.success) {
|
||||
console.log(`[Undo] Restored original file to ${inputStub.localFilePath}`);
|
||||
} else if (result.error && !result.error.includes('not available in web mode')) {
|
||||
console.warn('[Undo] Failed to restore file:', result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear results and operation tracking
|
||||
resetResults();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Core stub - no-op implementation for web builds
|
||||
// Desktop overrides this with actual Tauri implementation
|
||||
|
||||
export interface FileWithPath {
|
||||
file: File;
|
||||
path: string;
|
||||
quickKey: string;
|
||||
}
|
||||
|
||||
export interface FileDialogOptions {
|
||||
multiple?: boolean;
|
||||
filters?: Array<{
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open native file dialog and read selected files
|
||||
* Core stub - returns empty array (no native dialog in web)
|
||||
* Desktop builds override this with actual Tauri implementation
|
||||
*/
|
||||
export async function openFileDialog(
|
||||
_options?: FileDialogOptions
|
||||
): Promise<FileWithPath[]> {
|
||||
// Web build: no native file dialog support
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Core stub - no-op implementation for web builds
|
||||
// Desktop overrides this with actual Tauri implementation
|
||||
|
||||
export interface SaveResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MultiFileSaveResult {
|
||||
success: boolean;
|
||||
savedCount: number;
|
||||
cancelledByUser?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file data to a local filesystem path
|
||||
* Core stub - always returns failure
|
||||
* Desktop builds override this with actual implementation
|
||||
*/
|
||||
export async function saveToLocalPath(
|
||||
_data: Blob | File,
|
||||
_filePath: string
|
||||
): Promise<SaveResult> {
|
||||
return { success: false, error: "Local file save not available in web mode" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-save should be performed
|
||||
* Core stub - always returns false
|
||||
*/
|
||||
export function shouldAutoSave(_inputCount: number, _outputCount: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from local filesystem
|
||||
* Core stub - always returns failure
|
||||
*/
|
||||
export async function deleteLocalFile(_filePath: string): Promise<SaveResult> {
|
||||
return { success: false, error: "Local file delete not available in web mode" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Show native save dialog
|
||||
* Core stub - always returns null
|
||||
*/
|
||||
export async function showSaveDialog(
|
||||
_defaultFilename: string,
|
||||
_defaultDirectory?: string
|
||||
): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select folder and save multiple files
|
||||
* Core stub - always returns failure
|
||||
*/
|
||||
export async function saveMultipleFilesWithPrompt(
|
||||
_files: (Blob | File)[],
|
||||
_defaultDirectory?: string
|
||||
): Promise<MultiFileSaveResult> {
|
||||
return { success: false, savedCount: 0, error: "Multi-file save not available in web mode" };
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export interface StirlingFileStub extends BaseFileMetadata {
|
||||
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
|
||||
blobUrl?: string; // File access blob URL for downloads/processing
|
||||
localFilePath?: string; // Original local filesystem path (desktop app only)
|
||||
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
|
||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||
isPinned?: boolean; // Protected from tool consumption (replace/remove)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||
import { fileOpenService } from '@app/services/fileOpenService';
|
||||
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||
import { createQuickKey } from '@app/types/fileContext';
|
||||
|
||||
/**
|
||||
* App initialization hook
|
||||
@@ -11,7 +12,7 @@ import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||
*/
|
||||
export function useAppInitialization(): void {
|
||||
// Get file management actions
|
||||
const { addFiles } = useFileManagement();
|
||||
const { addFiles, updateStirlingFileStub } = useFileManagement();
|
||||
|
||||
// Handle files opened with app (Tauri mode)
|
||||
const { openedFilePaths, loading: openedFileLoading } = useOpenedFile();
|
||||
@@ -24,28 +25,45 @@ export function useAppInitialization(): void {
|
||||
|
||||
const loadOpenedFiles = async () => {
|
||||
try {
|
||||
const filesArray: File[] = [];
|
||||
const loadedFiles = (
|
||||
await Promise.all(
|
||||
openedFilePaths.map(async (filePath) => {
|
||||
try {
|
||||
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (!fileData) return null;
|
||||
|
||||
await Promise.all(
|
||||
openedFilePaths.map(async (filePath) => {
|
||||
try {
|
||||
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
|
||||
if (fileData) {
|
||||
const file = new File([fileData.arrayBuffer], fileData.fileName, {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
filesArray.push(file);
|
||||
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load file:', filePath, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (filesArray.length > 0) {
|
||||
await addFiles(filesArray);
|
||||
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
|
||||
console.log('[Desktop] Loaded file:', fileData.fileName);
|
||||
|
||||
return {
|
||||
file,
|
||||
filePath,
|
||||
quickKey: createQuickKey(file),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load file:', filePath, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter((entry): entry is { file: File; filePath: string; quickKey: string } => Boolean(entry));
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
const filesArray = loadedFiles.map(entry => entry.file);
|
||||
const quickKeyToPath = new Map(loadedFiles.map(entry => [entry.quickKey, entry.filePath]));
|
||||
|
||||
const addedFiles = await addFiles(filesArray);
|
||||
addedFiles.forEach(file => {
|
||||
const localFilePath = quickKeyToPath.get(file.quickKey);
|
||||
if (localFilePath) {
|
||||
updateStirlingFileStub(file.fileId, { localFilePath });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Desktop] Failed to load opened files:', error);
|
||||
@@ -53,7 +71,7 @@ export function useAppInitialization(): void {
|
||||
};
|
||||
|
||||
loadOpenedFiles();
|
||||
}, [openedFilePaths, openedFileLoading, addFiles]);
|
||||
}, [openedFilePaths, openedFileLoading, addFiles, updateStirlingFileStub]);
|
||||
}
|
||||
|
||||
export function useSetupCompletion(): (completed: boolean) => void {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Desktop implementation - Tauri native file dialogs
|
||||
import { createQuickKey } from '@app/types/fileContext';
|
||||
|
||||
export interface FileWithPath {
|
||||
file: File;
|
||||
path: string;
|
||||
quickKey: string;
|
||||
}
|
||||
|
||||
export interface FileDialogOptions {
|
||||
multiple?: boolean;
|
||||
filters?: Array<{
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open native file dialog and read selected files (Desktop/Tauri only)
|
||||
*/
|
||||
export async function openFileDialog(
|
||||
options?: FileDialogOptions
|
||||
): Promise<FileWithPath[]> {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const { readFile } = await import('@tauri-apps/plugin-fs');
|
||||
|
||||
console.log('[FileDialog] Opening file dialog...');
|
||||
const selectedPaths = await open({
|
||||
multiple: options?.multiple ?? true,
|
||||
filters: options?.filters ?? [{
|
||||
name: 'Documents',
|
||||
extensions: ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'tiff', 'bmp', 'html', 'zip']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!selectedPaths) {
|
||||
console.log('[FileDialog] User cancelled');
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths = Array.isArray(selectedPaths) ? selectedPaths : [selectedPaths];
|
||||
console.log(`[FileDialog] Selected ${paths.length} file(s):`, paths);
|
||||
|
||||
const filesWithPaths: FileWithPath[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
console.log(`[FileDialog] Reading file: ${filePath}`);
|
||||
const fileData = await readFile(filePath);
|
||||
const fileName = filePath.split(/[/\\]/).pop() || 'document';
|
||||
const file = new File([fileData], fileName, {
|
||||
type: fileName.endsWith('.pdf') ? 'application/pdf' : undefined
|
||||
});
|
||||
const quickKey = createQuickKey(file);
|
||||
console.log(`[FileDialog] Created File: ${fileName}, quickKey: ${quickKey}`);
|
||||
|
||||
filesWithPaths.push({
|
||||
file,
|
||||
path: filePath,
|
||||
quickKey
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[FileDialog] Failed to read ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return filesWithPaths;
|
||||
} catch (error) {
|
||||
console.error('[FileDialog] Error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
export interface SaveResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file data to a local filesystem path (Tauri desktop only)
|
||||
*
|
||||
* @param data - Blob or File to save
|
||||
* @param filePath - Absolute path to save to
|
||||
* @returns Result indicating success or failure with error message
|
||||
*/
|
||||
export async function saveToLocalPath(
|
||||
data: Blob | File,
|
||||
filePath: string
|
||||
): Promise<SaveResult> {
|
||||
if (!isTauri()) {
|
||||
return { success: false, error: "Not running in Tauri desktop app" };
|
||||
}
|
||||
|
||||
try {
|
||||
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||
const arrayBuffer = await data.arrayBuffer();
|
||||
await writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[LocalFileSave] Failed to save:', message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-save should be performed for this operation
|
||||
*
|
||||
* @param inputCount - Number of input files
|
||||
* @param outputCount - Number of output files
|
||||
* @returns True if auto-save conditions are met
|
||||
*/
|
||||
export function shouldAutoSave(inputCount: number, outputCount: number): boolean {
|
||||
return isTauri() && inputCount === 1 && outputCount === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from local filesystem (Tauri desktop only)
|
||||
*
|
||||
* @param filePath - Absolute path to delete
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
export async function deleteLocalFile(filePath: string): Promise<SaveResult> {
|
||||
if (!isTauri()) {
|
||||
return { success: false, error: "Not running in Tauri desktop app" };
|
||||
}
|
||||
|
||||
try {
|
||||
const { remove } = await import("@tauri-apps/plugin-fs");
|
||||
await remove(filePath);
|
||||
console.log(`[LocalFileDelete] Deleted: ${filePath}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[LocalFileDelete] Failed to delete:', message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show native save dialog and return selected path
|
||||
*
|
||||
* @param defaultFilename - Suggested filename
|
||||
* @param defaultDirectory - Optional default directory
|
||||
* @returns Selected file path or null if cancelled
|
||||
*/
|
||||
export async function showSaveDialog(
|
||||
defaultFilename: string,
|
||||
defaultDirectory?: string
|
||||
): Promise<string | null> {
|
||||
if (!isTauri()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
|
||||
const selectedPath = await save({
|
||||
defaultPath: defaultDirectory ? `${defaultDirectory}/${defaultFilename}` : defaultFilename,
|
||||
filters: [{
|
||||
name: 'PDF',
|
||||
extensions: ['pdf']
|
||||
}],
|
||||
title: 'Save As'
|
||||
});
|
||||
|
||||
return selectedPath;
|
||||
} catch (error) {
|
||||
console.error('[SaveDialog] Failed to show dialog:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MultiFileSaveResult {
|
||||
success: boolean;
|
||||
savedCount: number;
|
||||
cancelledByUser?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select a folder and save multiple files there
|
||||
*
|
||||
* @param files - Array of files to save
|
||||
* @param defaultDirectory - Optional default directory to open dialog in
|
||||
* @returns Result with count of files saved
|
||||
*/
|
||||
export async function saveMultipleFilesWithPrompt(
|
||||
files: (Blob | File)[],
|
||||
defaultDirectory?: string
|
||||
): Promise<MultiFileSaveResult> {
|
||||
if (!isTauri()) {
|
||||
return { success: false, savedCount: 0, error: "Not running in Tauri desktop app" };
|
||||
}
|
||||
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||
const { join } = await import("@tauri-apps/api/path");
|
||||
|
||||
// Prompt user to select folder
|
||||
const selectedFolder = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: defaultDirectory,
|
||||
title: `Save ${files.length} file${files.length > 1 ? 's' : ''}`
|
||||
});
|
||||
|
||||
// User cancelled
|
||||
if (!selectedFolder) {
|
||||
return { success: false, savedCount: 0, cancelledByUser: true };
|
||||
}
|
||||
|
||||
// Save each file to the selected folder
|
||||
let savedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const fileName = file instanceof File ? file.name : `output_${savedCount + 1}.pdf`;
|
||||
const filePath = await join(selectedFolder as string, fileName);
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||
savedCount++;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${file instanceof File ? file.name : 'file'}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedCount === files.length) {
|
||||
return { success: true, savedCount };
|
||||
} else if (savedCount > 0) {
|
||||
return {
|
||||
success: false,
|
||||
savedCount,
|
||||
error: `Saved ${savedCount}/${files.length} files. Errors: ${errors.join(', ')}`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
savedCount: 0,
|
||||
error: `Failed to save files: ${errors.join(', ')}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[LocalFileSave] Failed to save multiple files:', message);
|
||||
return { success: false, savedCount: 0, error: message };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user