refactor pdf decrypt
This commit is contained in:
@@ -2,9 +2,9 @@ import { showAlert } from '../ui.js';
|
||||
import {
|
||||
downloadFile,
|
||||
formatBytes,
|
||||
initializeQpdf,
|
||||
readFileAsArrayBuffer,
|
||||
} from '../utils/helpers.js';
|
||||
import { decryptPdfBytes } from '../utils/pdf-decrypt.js';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import JSZip from 'jszip';
|
||||
import { DecryptPdfState } from '@/types';
|
||||
@@ -115,79 +115,39 @@ async function decryptPdf() {
|
||||
|
||||
const loaderModal = document.getElementById('loader-modal');
|
||||
const loaderText = document.getElementById('loader-text');
|
||||
let qpdf: any;
|
||||
|
||||
try {
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
if (loaderText) loaderText.textContent = 'Initializing decryption...';
|
||||
|
||||
qpdf = await initializeQpdf();
|
||||
|
||||
if (pageState.files.length === 1) {
|
||||
// Single file: decrypt and download directly
|
||||
const file = pageState.files[0];
|
||||
const inputPath = '/input.pdf';
|
||||
const outputPath = '/output.pdf';
|
||||
if (loaderText) loaderText.textContent = 'Reading encrypted PDF...';
|
||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||
|
||||
try {
|
||||
if (loaderText) loaderText.textContent = 'Reading encrypted PDF...';
|
||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
||||
if (loaderText) loaderText.textContent = 'Decrypting PDF...';
|
||||
const { bytes: decryptedBytes } = await decryptPdfBytes(
|
||||
uint8Array,
|
||||
password
|
||||
);
|
||||
|
||||
if (loaderText) loaderText.textContent = 'Decrypting PDF...';
|
||||
const args = [
|
||||
inputPath,
|
||||
'--password=' + password,
|
||||
'--decrypt',
|
||||
outputPath,
|
||||
];
|
||||
if (loaderText) loaderText.textContent = 'Preparing download...';
|
||||
const blob = new Blob([decryptedBytes.slice().buffer], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `unlocked-${file.name}`);
|
||||
|
||||
try {
|
||||
qpdf.callMain(args);
|
||||
} catch (qpdfError: any) {
|
||||
if (
|
||||
qpdfError.message?.includes('invalid password') ||
|
||||
qpdfError.message?.includes('password')
|
||||
) {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
}
|
||||
throw qpdfError;
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF decrypted successfully! Your download has started.',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
|
||||
if (loaderText) loaderText.textContent = 'Preparing download...';
|
||||
const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
|
||||
if (outputFile.length === 0) {
|
||||
throw new Error('Decryption resulted in an empty file.');
|
||||
}
|
||||
|
||||
const blob = new Blob([new Uint8Array(outputFile)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `unlocked-${file.name}`);
|
||||
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF decrypted successfully! Your download has started.',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (qpdf?.FS) {
|
||||
if (qpdf.FS.analyzePath(inputPath).exists)
|
||||
qpdf.FS.unlink(inputPath);
|
||||
if (qpdf.FS.analyzePath(outputPath).exists)
|
||||
qpdf.FS.unlink(outputPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup WASM FS:', cleanupError);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Multiple files: decrypt all and download as ZIP
|
||||
const zip = new JSZip();
|
||||
@@ -196,8 +156,6 @@ async function decryptPdf() {
|
||||
|
||||
for (let i = 0; i < pageState.files.length; i++) {
|
||||
const file = pageState.files[i];
|
||||
const inputPath = `/input_${i}.pdf`;
|
||||
const outputPath = `/output_${i}.pdf`;
|
||||
|
||||
if (loaderText)
|
||||
loaderText.textContent = `Decrypting ${file.name} (${i + 1}/${pageState.files.length})...`;
|
||||
@@ -205,55 +163,16 @@ async function decryptPdf() {
|
||||
try {
|
||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
||||
const { bytes: decryptedBytes } = await decryptPdfBytes(
|
||||
uint8Array,
|
||||
password
|
||||
);
|
||||
|
||||
const args = [
|
||||
inputPath,
|
||||
'--password=' + password,
|
||||
'--decrypt',
|
||||
outputPath,
|
||||
];
|
||||
|
||||
try {
|
||||
qpdf.callMain(args);
|
||||
} catch (qpdfError: any) {
|
||||
if (
|
||||
qpdfError.message?.includes('invalid password') ||
|
||||
qpdfError.message?.includes('password')
|
||||
) {
|
||||
throw new Error(`Invalid password for ${file.name}`);
|
||||
}
|
||||
throw qpdfError;
|
||||
}
|
||||
|
||||
const outputFile = qpdf.FS.readFile(outputPath, {
|
||||
encoding: 'binary',
|
||||
});
|
||||
if (!outputFile || outputFile.length === 0) {
|
||||
throw new Error(
|
||||
`Decryption resulted in an empty file for ${file.name}.`
|
||||
);
|
||||
}
|
||||
|
||||
zip.file(`unlocked-${file.name}`, outputFile, { binary: true });
|
||||
zip.file(`unlocked-${file.name}`, decryptedBytes, { binary: true });
|
||||
successCount++;
|
||||
} catch (fileError: any) {
|
||||
errorCount++;
|
||||
console.error(`Failed to decrypt ${file.name}:`, fileError);
|
||||
} finally {
|
||||
try {
|
||||
if (qpdf?.FS) {
|
||||
if (qpdf.FS.analyzePath(inputPath).exists)
|
||||
qpdf.FS.unlink(inputPath);
|
||||
if (qpdf.FS.analyzePath(outputPath).exists)
|
||||
qpdf.FS.unlink(outputPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
`Failed to cleanup WASM FS for ${file.name}:`,
|
||||
cleanupError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
172
src/js/utils/pdf-decrypt.ts
Normal file
172
src/js/utils/pdf-decrypt.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { getCpdf, isCpdfAvailable } from './cpdf-helper';
|
||||
import { isPyMuPDFAvailable, loadPyMuPDF } from './pymupdf-loader';
|
||||
|
||||
export type PdfDecryptEngine = 'cpdf' | 'pymupdf';
|
||||
|
||||
export interface PdfDecryptResult {
|
||||
bytes: Uint8Array;
|
||||
engine: PdfDecryptEngine;
|
||||
}
|
||||
|
||||
const DECRYPT_LOG_PREFIX = '[PDF Decrypt]';
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
const message = getErrorMessage(error).trim();
|
||||
const cpdfReasonMatch = message.match(/Pdf\.PDFError\("([^"]+)"\)/);
|
||||
if (cpdfReasonMatch?.[1]) {
|
||||
return cpdfReasonMatch[1];
|
||||
}
|
||||
|
||||
const trailingErrorMatch = message.match(/ERROR:\s*[^:]+:\s*(.+)$/);
|
||||
if (trailingErrorMatch?.[1]) {
|
||||
return trailingErrorMatch[1].trim();
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function copyBytes(bytes: Uint8Array): Uint8Array {
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function cleanupCpdfDocument(cpdf: unknown, pdf: unknown): void {
|
||||
if (!cpdf || !pdf || typeof cpdf !== 'object' || !('deletePdf' in cpdf)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
(cpdf as { deletePdf: (document: unknown) => void }).deletePdf(pdf);
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
`${DECRYPT_LOG_PREFIX} Failed to cleanup CoherentPDF document: ${normalizeErrorMessage(cleanupError)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptWithCpdf(
|
||||
inputBytes: Uint8Array,
|
||||
password: string
|
||||
): Promise<Uint8Array> {
|
||||
const cpdf = await getCpdf();
|
||||
cpdf.setSlow();
|
||||
|
||||
let pdf: unknown | null = null;
|
||||
|
||||
try {
|
||||
pdf = cpdf.fromMemory(new Uint8Array(inputBytes), password);
|
||||
|
||||
if (cpdf.isEncrypted(pdf)) {
|
||||
try {
|
||||
cpdf.decryptPdf(pdf, password);
|
||||
} catch {
|
||||
cpdf.decryptPdfOwner(pdf, password);
|
||||
}
|
||||
}
|
||||
|
||||
const outputBytes = cpdf.toMemory(pdf, false, false);
|
||||
pdf = null;
|
||||
|
||||
if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) {
|
||||
throw new Error('CoherentPDF produced an empty decrypted file.');
|
||||
}
|
||||
|
||||
return copyBytes(outputBytes);
|
||||
} catch (error) {
|
||||
if (pdf) {
|
||||
cleanupCpdfDocument(cpdf, pdf);
|
||||
}
|
||||
|
||||
throw new Error(normalizeErrorMessage(error), { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptWithPyMuPDF(
|
||||
inputBytes: Uint8Array,
|
||||
password: string
|
||||
): Promise<Uint8Array> {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const document = await pymupdf.open(
|
||||
new Blob([new Uint8Array(inputBytes)], { type: 'application/pdf' })
|
||||
);
|
||||
|
||||
try {
|
||||
if (document.needsPass || document.isEncrypted) {
|
||||
const authenticated = document.authenticate(password);
|
||||
if (!authenticated) {
|
||||
throw new Error('Invalid PDF password.');
|
||||
}
|
||||
}
|
||||
|
||||
const outputBytes = document.save();
|
||||
if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) {
|
||||
throw new Error('PyMuPDF produced an empty decrypted file.');
|
||||
}
|
||||
|
||||
return copyBytes(outputBytes);
|
||||
} finally {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptPdfBytes(
|
||||
inputBytes: Uint8Array,
|
||||
password: string
|
||||
): Promise<PdfDecryptResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (isCpdfAvailable()) {
|
||||
console.info(`${DECRYPT_LOG_PREFIX} Trying CoherentPDF decryption`);
|
||||
try {
|
||||
const result: PdfDecryptResult = {
|
||||
bytes: await decryptWithCpdf(inputBytes, password),
|
||||
engine: 'cpdf',
|
||||
};
|
||||
console.info(
|
||||
`${DECRYPT_LOG_PREFIX} Decryption succeeded with CoherentPDF`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = normalizeErrorMessage(error);
|
||||
console.warn(
|
||||
`${DECRYPT_LOG_PREFIX} Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: ${errorMessage}`
|
||||
);
|
||||
errors.push(`CoherentPDF: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.info(
|
||||
`${DECRYPT_LOG_PREFIX} CoherentPDF is not configured, skipping to PyMuPDF`
|
||||
);
|
||||
errors.push('CoherentPDF: not configured.');
|
||||
}
|
||||
|
||||
if (isPyMuPDFAvailable()) {
|
||||
console.info(`${DECRYPT_LOG_PREFIX} Trying PyMuPDF decryption`);
|
||||
try {
|
||||
const result: PdfDecryptResult = {
|
||||
bytes: await decryptWithPyMuPDF(inputBytes, password),
|
||||
engine: 'pymupdf',
|
||||
};
|
||||
console.info(`${DECRYPT_LOG_PREFIX} Decryption succeeded with PyMuPDF`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = normalizeErrorMessage(error);
|
||||
console.warn(
|
||||
`${DECRYPT_LOG_PREFIX} PyMuPDF decryption failed: ${errorMessage}`
|
||||
);
|
||||
errors.push(`PyMuPDF: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`${DECRYPT_LOG_PREFIX} PyMuPDF is not configured`);
|
||||
errors.push('PyMuPDF: not configured.');
|
||||
}
|
||||
|
||||
throw new Error(errors.join('\n'));
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { initializeQpdf } from '../../utils/helpers.js';
|
||||
import { decryptPdfBytes } from '../../utils/pdf-decrypt.js';
|
||||
|
||||
export class DecryptNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
@@ -33,35 +33,13 @@ export class DecryptNode extends BaseWorkflowNode {
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const qpdf = await initializeQpdf();
|
||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const inputPath = `/tmp/input_decrypt_${uid}.pdf`;
|
||||
const outputPath = `/tmp/output_decrypt_${uid}.pdf`;
|
||||
|
||||
let decryptedData: Uint8Array;
|
||||
try {
|
||||
qpdf.FS.writeFile(inputPath, input.bytes);
|
||||
const args = password
|
||||
? [inputPath, '--password=' + password, '--decrypt', outputPath]
|
||||
: [inputPath, '--decrypt', outputPath];
|
||||
qpdf.callMain(args);
|
||||
|
||||
decryptedData = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
} finally {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = new Uint8Array(decryptedData);
|
||||
const document = await PDFDocument.load(resultBytes);
|
||||
const { bytes: resultBytes } = await decryptPdfBytes(
|
||||
input.bytes,
|
||||
password
|
||||
);
|
||||
const document = await PDFDocument.load(resultBytes, {
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
|
||||
@@ -3,7 +3,8 @@ import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { readFileAsArrayBuffer, initializeQpdf } from '../../utils/helpers.js';
|
||||
import { readFileAsArrayBuffer } from '../../utils/helpers.js';
|
||||
import { decryptPdfBytes } from '../../utils/pdf-decrypt.js';
|
||||
|
||||
export class EncryptedPDFError extends Error {
|
||||
constructor(public readonly filename: string) {
|
||||
@@ -63,44 +64,16 @@ export class PDFInputNode extends BaseWorkflowNode {
|
||||
async addDecryptedFile(file: File, password: string): Promise<void> {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
const bytes = new Uint8Array(arrayBuffer as ArrayBuffer);
|
||||
const qpdf = await initializeQpdf();
|
||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const inputPath = `/tmp/input_decrypt_${uid}.pdf`;
|
||||
const outputPath = `/tmp/output_decrypt_${uid}.pdf`;
|
||||
|
||||
try {
|
||||
qpdf.FS.writeFile(inputPath, bytes);
|
||||
qpdf.callMain([
|
||||
inputPath,
|
||||
'--password=' + password,
|
||||
'--decrypt',
|
||||
outputPath,
|
||||
]);
|
||||
const decryptedData = qpdf.FS.readFile(outputPath, {
|
||||
encoding: 'binary',
|
||||
});
|
||||
const decryptedBytes = new Uint8Array(decryptedData);
|
||||
const document = await PDFDocument.load(decryptedBytes, {
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
this.files.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes: decryptedBytes,
|
||||
filename: file.name,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
}
|
||||
const { bytes: decryptedBytes } = await decryptPdfBytes(bytes, password);
|
||||
const document = await PDFDocument.load(decryptedBytes, {
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
this.files.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes: decryptedBytes,
|
||||
filename: file.name,
|
||||
});
|
||||
}
|
||||
|
||||
async setFile(file: File): Promise<void> {
|
||||
|
||||
276
src/tests/pdf-decrypt.test.ts
Normal file
276
src/tests/pdf-decrypt.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetCpdf,
|
||||
mockIsCpdfAvailable,
|
||||
mockLoadPyMuPDF,
|
||||
mockIsPyMuPDFAvailable,
|
||||
mockPdfDocumentLoad,
|
||||
mockReadFileAsArrayBuffer,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetCpdf: vi.fn(),
|
||||
mockIsCpdfAvailable: vi.fn(),
|
||||
mockLoadPyMuPDF: vi.fn(),
|
||||
mockIsPyMuPDFAvailable: vi.fn(),
|
||||
mockPdfDocumentLoad: vi.fn(),
|
||||
mockReadFileAsArrayBuffer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/cpdf-helper', () => ({
|
||||
getCpdf: mockGetCpdf,
|
||||
isCpdfAvailable: mockIsCpdfAvailable,
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/pymupdf-loader', () => ({
|
||||
loadPyMuPDF: mockLoadPyMuPDF,
|
||||
isPyMuPDFAvailable: mockIsPyMuPDFAvailable,
|
||||
}));
|
||||
|
||||
vi.mock('pdf-lib', () => ({
|
||||
PDFDocument: {
|
||||
load: mockPdfDocumentLoad,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/helpers.js', () => ({
|
||||
readFileAsArrayBuffer: mockReadFileAsArrayBuffer,
|
||||
}));
|
||||
|
||||
import * as pdfDecryptModule from '../js/utils/pdf-decrypt';
|
||||
|
||||
describe('pdf decrypt', () => {
|
||||
let infoSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockIsCpdfAvailable.mockReturnValue(true);
|
||||
mockIsPyMuPDFAvailable.mockReturnValue(true);
|
||||
mockPdfDocumentLoad.mockResolvedValue({ loaded: true });
|
||||
mockReadFileAsArrayBuffer.mockResolvedValue(
|
||||
new Uint8Array([1, 2, 3]).buffer
|
||||
);
|
||||
});
|
||||
|
||||
it('uses CoherentPDF first when it succeeds', async () => {
|
||||
const cpdf = {
|
||||
setSlow: vi.fn(),
|
||||
fromMemory: vi.fn().mockReturnValue({ pdf: true }),
|
||||
isEncrypted: vi.fn().mockReturnValue(true),
|
||||
decryptPdf: vi.fn(),
|
||||
decryptPdfOwner: vi.fn(),
|
||||
toMemory: vi.fn().mockReturnValue(new Uint8Array([4, 5, 6])),
|
||||
deletePdf: vi.fn(),
|
||||
};
|
||||
mockGetCpdf.mockResolvedValue(cpdf);
|
||||
|
||||
const result = await pdfDecryptModule.decryptPdfBytes(
|
||||
new Uint8Array([9]),
|
||||
'1234'
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
bytes: new Uint8Array([4, 5, 6]),
|
||||
engine: 'cpdf',
|
||||
});
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'[PDF Decrypt] Decryption succeeded with CoherentPDF'
|
||||
);
|
||||
expect(cpdf.decryptPdf).toHaveBeenCalledWith({ pdf: true }, '1234');
|
||||
expect(mockLoadPyMuPDF).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to PyMuPDF when CoherentPDF fails', async () => {
|
||||
const cpdf = {
|
||||
setSlow: vi.fn(),
|
||||
fromMemory: vi.fn().mockImplementation(() => {
|
||||
throw new Error('cpdf failed');
|
||||
}),
|
||||
isEncrypted: vi.fn(),
|
||||
decryptPdf: vi.fn(),
|
||||
decryptPdfOwner: vi.fn(),
|
||||
toMemory: vi.fn(),
|
||||
deletePdf: vi.fn(),
|
||||
};
|
||||
const pymupdfDocument = {
|
||||
needsPass: true,
|
||||
isEncrypted: true,
|
||||
authenticate: vi.fn().mockReturnValue(true),
|
||||
save: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetCpdf.mockResolvedValue(cpdf);
|
||||
mockLoadPyMuPDF.mockResolvedValue({
|
||||
open: vi.fn().mockResolvedValue(pymupdfDocument),
|
||||
});
|
||||
|
||||
const result = await pdfDecryptModule.decryptPdfBytes(
|
||||
new Uint8Array([9]),
|
||||
'1234'
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
bytes: new Uint8Array([7, 8, 9]),
|
||||
engine: 'pymupdf',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[PDF Decrypt] Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: cpdf failed'
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'[PDF Decrypt] Decryption succeeded with PyMuPDF'
|
||||
);
|
||||
expect(pymupdfDocument.authenticate).toHaveBeenCalledWith('1234');
|
||||
expect(pymupdfDocument.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes raw CoherentPDF error strings before fallback logging', async () => {
|
||||
const cpdf = {
|
||||
setSlow: vi.fn(),
|
||||
fromMemory: vi.fn().mockImplementation(() => {
|
||||
throw '0,248,Exports.CPDFError,32,ERROR: decryptPdfOwner: Pdf.PDFError("Bad or missing /P entry")';
|
||||
}),
|
||||
isEncrypted: vi.fn(),
|
||||
decryptPdf: vi.fn(),
|
||||
decryptPdfOwner: vi.fn(),
|
||||
toMemory: vi.fn(),
|
||||
deletePdf: vi.fn(),
|
||||
};
|
||||
const pymupdfDocument = {
|
||||
needsPass: false,
|
||||
isEncrypted: false,
|
||||
authenticate: vi.fn(),
|
||||
save: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetCpdf.mockResolvedValue(cpdf);
|
||||
mockLoadPyMuPDF.mockResolvedValue({
|
||||
open: vi.fn().mockResolvedValue(pymupdfDocument),
|
||||
});
|
||||
|
||||
const result = await pdfDecryptModule.decryptPdfBytes(
|
||||
new Uint8Array([9]),
|
||||
'1234'
|
||||
);
|
||||
|
||||
expect(result.engine).toBe('pymupdf');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[PDF Decrypt] Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: Bad or missing /P entry'
|
||||
);
|
||||
});
|
||||
|
||||
it('aggregates engine errors when decryption fails', async () => {
|
||||
const cpdf = {
|
||||
setSlow: vi.fn(),
|
||||
fromMemory: vi.fn().mockImplementation(() => {
|
||||
throw new Error('cpdf failed');
|
||||
}),
|
||||
isEncrypted: vi.fn(),
|
||||
decryptPdf: vi.fn(),
|
||||
decryptPdfOwner: vi.fn(),
|
||||
toMemory: vi.fn(),
|
||||
deletePdf: vi.fn(),
|
||||
};
|
||||
const pymupdfDocument = {
|
||||
needsPass: true,
|
||||
isEncrypted: true,
|
||||
authenticate: vi.fn().mockReturnValue(false),
|
||||
save: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
mockGetCpdf.mockResolvedValue(cpdf);
|
||||
mockLoadPyMuPDF.mockResolvedValue({
|
||||
open: vi.fn().mockResolvedValue(pymupdfDocument),
|
||||
});
|
||||
|
||||
await expect(
|
||||
pdfDecryptModule.decryptPdfBytes(new Uint8Array([9]), '1234')
|
||||
).rejects.toThrow(
|
||||
'CoherentPDF: cpdf failed\nPyMuPDF: Invalid PDF password.'
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[PDF Decrypt] PyMuPDF decryption failed: Invalid PDF password.'
|
||||
);
|
||||
expect(pymupdfDocument.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the shared decrypt helper in the workflow decrypt node', async () => {
|
||||
const decryptSpy = vi
|
||||
.spyOn(pdfDecryptModule, 'decryptPdfBytes')
|
||||
.mockResolvedValue({
|
||||
bytes: new Uint8Array([8, 8, 8]),
|
||||
engine: 'cpdf',
|
||||
});
|
||||
|
||||
const { DecryptNode } = await import('../js/workflow/nodes/decrypt-node');
|
||||
|
||||
const node = new DecryptNode();
|
||||
const passwordControl = node.controls['password'] as { value?: string };
|
||||
passwordControl.value = 'secret';
|
||||
|
||||
const result = await node.data({
|
||||
pdf: [
|
||||
{
|
||||
type: 'pdf',
|
||||
document: {} as never,
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
filename: 'locked.pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(decryptSpy).toHaveBeenCalledWith(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
'secret'
|
||||
);
|
||||
expect(mockPdfDocumentLoad).toHaveBeenCalledWith(
|
||||
new Uint8Array([8, 8, 8]),
|
||||
{
|
||||
throwOnInvalidObject: false,
|
||||
}
|
||||
);
|
||||
expect(result.pdf).toEqual({
|
||||
type: 'pdf',
|
||||
document: { loaded: true },
|
||||
bytes: new Uint8Array([8, 8, 8]),
|
||||
filename: 'locked_decrypted.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the shared decrypt helper when adding a decrypted workflow input file', async () => {
|
||||
const decryptSpy = vi
|
||||
.spyOn(pdfDecryptModule, 'decryptPdfBytes')
|
||||
.mockResolvedValue({
|
||||
bytes: new Uint8Array([6, 6, 6]),
|
||||
engine: 'cpdf',
|
||||
});
|
||||
|
||||
const { PDFInputNode } =
|
||||
await import('../js/workflow/nodes/pdf-input-node');
|
||||
|
||||
const node = new PDFInputNode();
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'locked.pdf', {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
await node.addDecryptedFile(file, 'secret');
|
||||
|
||||
expect(mockReadFileAsArrayBuffer).toHaveBeenCalledWith(file);
|
||||
expect(decryptSpy).toHaveBeenCalledWith(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
'secret'
|
||||
);
|
||||
expect(mockPdfDocumentLoad).toHaveBeenCalledWith(
|
||||
new Uint8Array([6, 6, 6]),
|
||||
{
|
||||
throwOnInvalidObject: false,
|
||||
}
|
||||
);
|
||||
expect(node.getFileCount()).toBe(1);
|
||||
expect(node.getFilenames()).toEqual(['locked.pdf']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user