mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6daec4938 | ||
|
|
ede9b5da76 | ||
|
|
780d599fcf |
@@ -0,0 +1,62 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings";
|
||||
import { defaultParameters } from "@app/components/tools/addStamp/useAddStampParameters";
|
||||
|
||||
describe("StampSetupSettings image preview", () => {
|
||||
const onParameterChange = vi.fn();
|
||||
const stampImage = new File(["image"], "stamp.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderImageStamp = () =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<StampSetupSettings
|
||||
parameters={{
|
||||
...defaultParameters,
|
||||
stampType: "image",
|
||||
stampImage,
|
||||
}}
|
||||
onParameterChange={onParameterChange}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
it("creates one preview URL per selected image, not per render", () => {
|
||||
const createObjectURL = vi.mocked(URL.createObjectURL);
|
||||
const { rerender } = renderImageStamp();
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<StampSetupSettings
|
||||
parameters={{
|
||||
...defaultParameters,
|
||||
stampType: "image",
|
||||
stampImage,
|
||||
pageNumbers: "2",
|
||||
}}
|
||||
onParameterChange={onParameterChange}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("revokes the preview URL when the component unmounts", () => {
|
||||
const revokeObjectURL = vi.mocked(URL.revokeObjectURL);
|
||||
const { unmount } = renderImageStamp();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
@@ -209,6 +209,17 @@ const StampSetupSettings = ({
|
||||
filename,
|
||||
}: StampSetupSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const stampImageUrl = useMemo(
|
||||
() =>
|
||||
parameters.stampImage ? URL.createObjectURL(parameters.stampImage) : null,
|
||||
[parameters.stampImage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (stampImageUrl) URL.revokeObjectURL(stampImageUrl);
|
||||
};
|
||||
}, [stampImageUrl]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -679,10 +690,10 @@ const StampSetupSettings = ({
|
||||
>
|
||||
{t("chooseFile", "Choose File")}
|
||||
</Button>
|
||||
{parameters.stampImage && (
|
||||
{parameters.stampImage && stampImageUrl && (
|
||||
<Stack gap="xs">
|
||||
<img
|
||||
src={URL.createObjectURL(parameters.stampImage)}
|
||||
src={stampImageUrl}
|
||||
alt="Selected stamp image"
|
||||
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FileLifecycleManager } from "@app/contexts/file/lifecycle";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
describe("FileLifecycleManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("revokes tracked blob URLs during cleanup", () => {
|
||||
const fileId = "file-1" as FileId;
|
||||
const filesRef = {
|
||||
current: new Map<FileId, File>([[fileId, new File(["pdf"], "file.pdf")]]),
|
||||
};
|
||||
const dispatch = vi.fn();
|
||||
const manager = new FileLifecycleManager(filesRef, dispatch);
|
||||
const blobUrl = "blob:file-1";
|
||||
manager.trackBlobUrl(blobUrl);
|
||||
|
||||
manager.cleanupAllFiles();
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith(blobUrl);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,16 @@ export class FileLifecycleManager {
|
||||
}
|
||||
};
|
||||
|
||||
private revokeBlobUrl = (url: string): void => {
|
||||
if (!url.startsWith("blob:")) return;
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Ignore revocation errors.
|
||||
}
|
||||
this.blobUrls.delete(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up resources for a specific file (with stateRef access for complete cleanup)
|
||||
*/
|
||||
@@ -150,32 +160,14 @@ export class FileLifecycleManager {
|
||||
const record = stateRef.current.files.byId[fileId];
|
||||
if (record) {
|
||||
// Clean up thumbnail blob URLs
|
||||
if (record.thumbnailUrl && record.thumbnailUrl.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.thumbnailUrl);
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
if (record.thumbnailUrl) this.revokeBlobUrl(record.thumbnailUrl);
|
||||
|
||||
if (record.blobUrl && record.blobUrl.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.blobUrl);
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
if (record.blobUrl) this.revokeBlobUrl(record.blobUrl);
|
||||
|
||||
// Clean up processed file thumbnails
|
||||
if (record.processedFile?.pages) {
|
||||
record.processedFile.pages.forEach((page: ProcessedFilePage) => {
|
||||
if (page.thumbnail && page.thumbnail.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(page.thumbnail);
|
||||
} catch {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
if (page.thumbnail) this.revokeBlobUrl(page.thumbnail);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,6 +681,8 @@ export const useCompareOperation = (): CompareOperationHook => {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
pixelSignalRef.current.cancelled = true;
|
||||
cleanupDownloadUrl();
|
||||
revokePixelUrls();
|
||||
if (workerRef.current) {
|
||||
|
||||
@@ -47,6 +47,8 @@ export function useEnhancedProcessedFiles(
|
||||
const [processingStates, setProcessingStates] = useState<
|
||||
Map<string, ProcessingState>
|
||||
>(new Map());
|
||||
const processedFilesRef = useRef(processedFiles);
|
||||
processedFilesRef.current = processedFiles;
|
||||
|
||||
// Subscribe to processing state changes once
|
||||
useEffect(() => {
|
||||
@@ -57,6 +59,7 @@ export function useEnhancedProcessedFiles(
|
||||
|
||||
// Process files when activeFiles changes
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
console.log(
|
||||
"useEnhancedProcessedFiles: activeFiles changed",
|
||||
activeFiles.length,
|
||||
@@ -77,6 +80,7 @@ export function useEnhancedProcessedFiles(
|
||||
const newProcessedFiles = new Map<File, ProcessedFile>();
|
||||
|
||||
for (const file of activeFiles) {
|
||||
if (cancelled) return;
|
||||
// Generate hash for this file
|
||||
const fileHash = await FileHasher.generateHybridHash(file);
|
||||
fileHashMapRef.current.set(file, fileHash);
|
||||
@@ -120,57 +124,67 @@ export function useEnhancedProcessedFiles(
|
||||
(file) => !processedFiles.has(file),
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
if (hasChanged && !cancelled) {
|
||||
processedFilesRef.current = newProcessedFiles;
|
||||
setProcessedFiles(newProcessedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
processFiles();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeFiles]); // Only depend on activeFiles to avoid infinite loops
|
||||
|
||||
// Listen for processing completion
|
||||
useEffect(() => {
|
||||
const checkInFlightRef = { current: false };
|
||||
const checkForCompletedFiles = async () => {
|
||||
if (checkInFlightRef.current) return;
|
||||
checkInFlightRef.current = true;
|
||||
let hasNewFiles = false;
|
||||
const updatedFiles = new Map(processedFiles);
|
||||
const updatedFiles = new Map(processedFilesRef.current);
|
||||
|
||||
// Generate file keys for all files first
|
||||
const fileKeyPromises = activeFiles.map(async (file) => ({
|
||||
file,
|
||||
key: await FileHasher.generateHybridHash(file),
|
||||
}));
|
||||
try {
|
||||
// Generate file keys for all files first
|
||||
const fileKeyPromises = activeFiles.map(async (file) => ({
|
||||
file,
|
||||
key: await FileHasher.generateHybridHash(file),
|
||||
}));
|
||||
|
||||
const fileKeyPairs = await Promise.all(fileKeyPromises);
|
||||
const fileKeyPairs = await Promise.all(fileKeyPromises);
|
||||
|
||||
for (const { file, key } of fileKeyPairs) {
|
||||
// Only check files that don't have processed results yet
|
||||
if (!updatedFiles.has(file)) {
|
||||
const processingState = processingStates.get(key);
|
||||
for (const { file, key } of fileKeyPairs) {
|
||||
// Only check files that don't have processed results yet
|
||||
if (!updatedFiles.has(file)) {
|
||||
const processingState = processingStates.get(key);
|
||||
|
||||
// Check for both processing and recently completed files
|
||||
// This ensures we catch completed files before they're cleaned up
|
||||
if (
|
||||
processingState?.status === "processing" ||
|
||||
processingState?.status === "completed"
|
||||
) {
|
||||
try {
|
||||
const processed = await enhancedPDFProcessingService.processFile(
|
||||
file,
|
||||
config,
|
||||
);
|
||||
if (processed) {
|
||||
updatedFiles.set(file, processed);
|
||||
hasNewFiles = true;
|
||||
// Check for both processing and recently completed files
|
||||
// This ensures we catch completed files before they're cleaned up
|
||||
if (
|
||||
processingState?.status === "processing" ||
|
||||
processingState?.status === "completed"
|
||||
) {
|
||||
try {
|
||||
const processed =
|
||||
await enhancedPDFProcessingService.processFile(file, config);
|
||||
if (processed) {
|
||||
updatedFiles.set(file, processed);
|
||||
hasNewFiles = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in completion check
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in completion check
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewFiles) {
|
||||
setProcessedFiles(updatedFiles);
|
||||
if (hasNewFiles) {
|
||||
processedFilesRef.current = updatedFiles;
|
||||
setProcessedFiles(updatedFiles);
|
||||
}
|
||||
} finally {
|
||||
checkInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -54,11 +54,12 @@ class PDFWorkerManager {
|
||||
disableStream?: boolean;
|
||||
stopAtErrors?: boolean;
|
||||
verbosity?: number;
|
||||
signal?: { cancelled: boolean };
|
||||
} = {},
|
||||
): Promise<PDFDocumentProxy> {
|
||||
// Wait if we've hit the worker limit
|
||||
if (this.activeDocuments.size >= this.maxWorkers) {
|
||||
await this.waitForAvailableWorker();
|
||||
await this.waitForAvailableWorker(options.signal);
|
||||
}
|
||||
|
||||
// Normalize input data to PDF.js format
|
||||
@@ -105,7 +106,7 @@ class PDFWorkerManager {
|
||||
// If document creation fails, make sure to clean up the loading task
|
||||
if (loadingTask) {
|
||||
try {
|
||||
loadingTask.destroy();
|
||||
void loadingTask.destroy();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
@@ -117,14 +118,13 @@ class PDFWorkerManager {
|
||||
/**
|
||||
* Properly destroy a PDF document and clean up resources
|
||||
*/
|
||||
destroyDocument(pdf: PDFDocumentProxy): void {
|
||||
async destroyDocument(pdf: PDFDocumentProxy): Promise<void> {
|
||||
if (this.activeDocuments.has(pdf)) {
|
||||
try {
|
||||
pdf.destroy();
|
||||
this.activeDocuments.delete(pdf);
|
||||
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||
await pdf.destroy();
|
||||
} catch {
|
||||
// Still remove from tracking even if destroy failed
|
||||
// Still remove from tracking if destroy fails.
|
||||
} finally {
|
||||
this.activeDocuments.delete(pdf);
|
||||
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||
}
|
||||
@@ -134,26 +134,31 @@ class PDFWorkerManager {
|
||||
/**
|
||||
* Destroy all active PDF documents
|
||||
*/
|
||||
destroyAllDocuments(): void {
|
||||
async destroyAllDocuments(): Promise<void> {
|
||||
const documentsToDestroy = Array.from(this.activeDocuments);
|
||||
documentsToDestroy.forEach((pdf) => {
|
||||
this.destroyDocument(pdf);
|
||||
});
|
||||
|
||||
this.activeDocuments.clear();
|
||||
this.workerCount = 0;
|
||||
await Promise.all(
|
||||
documentsToDestroy.map((pdf) => this.destroyDocument(pdf)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a worker to become available
|
||||
*/
|
||||
private async waitForAvailableWorker(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
private async waitForAvailableWorker(signal?: {
|
||||
cancelled: boolean;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const checkAvailability = () => {
|
||||
if (signal?.cancelled) {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
reject(new Error("CANCELLED"));
|
||||
return;
|
||||
}
|
||||
if (this.activeDocuments.size < this.maxWorkers) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkAvailability, 100);
|
||||
timer = setTimeout(checkAvailability, 100);
|
||||
}
|
||||
};
|
||||
checkAvailability();
|
||||
@@ -178,7 +183,7 @@ class PDFWorkerManager {
|
||||
// Force destroy all documents
|
||||
this.activeDocuments.forEach((pdf) => {
|
||||
try {
|
||||
pdf.destroy();
|
||||
void pdf.destroy();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
@@ -89,12 +89,17 @@ export const runPixelCompare = async ({
|
||||
};
|
||||
|
||||
return await new Promise<CompareResultPixelData>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let cancellationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const handleMessage = (event: MessageEvent<PixelCompareWorkerResponse>) => {
|
||||
if (settled) return;
|
||||
const message = event.data;
|
||||
if (!message) return;
|
||||
if (signal?.cancelled) {
|
||||
terminateWorker();
|
||||
cleanupOnFailure();
|
||||
settled = true;
|
||||
reject(new Error("CANCELLED"));
|
||||
return;
|
||||
}
|
||||
@@ -131,6 +136,7 @@ export const runPixelCompare = async ({
|
||||
case "success": {
|
||||
pages.sort((a, b) => a.pageNumber - b.pageNumber);
|
||||
terminateWorker();
|
||||
settled = true;
|
||||
resolve({
|
||||
mode: "pixel",
|
||||
base: { fileId: baseFileId, fileName: baseFile.name },
|
||||
@@ -151,6 +157,7 @@ export const runPixelCompare = async ({
|
||||
case "error": {
|
||||
terminateWorker();
|
||||
cleanupOnFailure();
|
||||
settled = true;
|
||||
reject(new Error(message.message));
|
||||
break;
|
||||
}
|
||||
@@ -158,14 +165,20 @@ export const runPixelCompare = async ({
|
||||
};
|
||||
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
if (settled) return;
|
||||
terminateWorker();
|
||||
cleanupOnFailure();
|
||||
settled = true;
|
||||
reject(
|
||||
event.error ?? new Error(event.message || "Pixel compare worker error"),
|
||||
);
|
||||
};
|
||||
|
||||
const terminateWorker = () => {
|
||||
if (cancellationTimer !== null) {
|
||||
clearInterval(cancellationTimer);
|
||||
cancellationTimer = null;
|
||||
}
|
||||
worker.removeEventListener("message", handleMessage as EventListener);
|
||||
worker.removeEventListener("error", handleError as EventListener);
|
||||
try {
|
||||
@@ -178,6 +191,17 @@ export const runPixelCompare = async ({
|
||||
worker.addEventListener("message", handleMessage as EventListener);
|
||||
worker.addEventListener("error", handleError as EventListener);
|
||||
|
||||
if (signal) {
|
||||
cancellationTimer = setInterval(() => {
|
||||
if (signal.cancelled && !settled) {
|
||||
terminateWorker();
|
||||
cleanupOnFailure();
|
||||
settled = true;
|
||||
reject(new Error("CANCELLED"));
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
const request: PixelCompareWorkerRequest = {
|
||||
type: "pixel-compare",
|
||||
payload: {
|
||||
|
||||
@@ -108,7 +108,11 @@ async function pageText(
|
||||
): Promise<string> {
|
||||
if (pageNo < 1 || pageNo > pdfDoc.numPages) return "";
|
||||
const page = await pdfDoc.getPage(pageNo);
|
||||
return textFromItems(await pageTextItems(page));
|
||||
try {
|
||||
return textFromItems(await pageTextItems(page));
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function textFromItems(items: readonly unknown[]): string {
|
||||
|
||||
Reference in New Issue
Block a user