zip download of single pdfs

This commit is contained in:
2026-05-17 02:57:39 +02:00
parent 13097b73fc
commit 4b0046a943
11 changed files with 272 additions and 27 deletions

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { unzipSync } from 'fflate';
import { createSplitResultsZip, createSplitZipFilename } from './pdfZipService';
import type { SplitResult } from './pdfTypes';
async function unzipBlob(blob: Blob): Promise<Record<string, Uint8Array>> {
const arrayBuffer = await blob.arrayBuffer();
return unzipSync(new Uint8Array(arrayBuffer));
}
describe('pdfZipService', () => {
it('creates a ZIP archive from split PDF blobs', async () => {
const results: SplitResult[] = [
{
pageIndex: 0,
filename: 'document_page_001.pdf',
blob: new Blob([new Uint8Array([1, 2, 3])], {
type: 'application/pdf',
}),
},
{
pageIndex: 1,
filename: 'document_page_002.pdf',
blob: new Blob([new Uint8Array([4, 5, 6])], {
type: 'application/pdf',
}),
},
];
const zipBlob = await createSplitResultsZip(results);
const entries = await unzipBlob(zipBlob);
expect(zipBlob.type).toBe('application/zip');
expect(Object.keys(entries)).toEqual([
'document_page_001.pdf',
'document_page_002.pdf',
]);
expect(Array.from(entries['document_page_001.pdf'])).toEqual([1, 2, 3]);
expect(Array.from(entries['document_page_002.pdf'])).toEqual([4, 5, 6]);
});
it('sanitizes and deduplicates ZIP entry names', async () => {
const results: SplitResult[] = [
{
pageIndex: 0,
filename: '../page.pdf',
blob: new Blob([new Uint8Array([1])], { type: 'application/pdf' }),
},
{
pageIndex: 1,
filename: '../page.pdf',
blob: new Blob([new Uint8Array([2])], { type: 'application/pdf' }),
},
{
pageIndex: 2,
filename: '',
blob: new Blob([new Uint8Array([3])], { type: 'application/pdf' }),
},
];
const zipBlob = await createSplitResultsZip(results);
const entries = await unzipBlob(zipBlob);
expect(Object.keys(entries)).toEqual([
'.._page.pdf',
'.._page_2.pdf',
'page_003.pdf',
]);
});
it('creates a readable ZIP filename from the source PDF name', () => {
expect(createSplitZipFilename('contract.pdf')).toBe(
'contract_split_pages.zip'
);
expect(createSplitZipFilename('contract.final.PDF')).toBe(
'contract.final_split_pages.zip'
);
expect(createSplitZipFilename('')).toBe('document_split_pages.zip');
});
it('rejects empty split results', async () => {
await expect(createSplitResultsZip([])).rejects.toThrow(
'Cannot create a ZIP archive without split results.'
);
});
});

78
src/pdf/pdfZipService.ts Normal file
View File

@@ -0,0 +1,78 @@
import { zipSync } from 'fflate';
import type { SplitResult } from './pdfTypes';
function bytesToBlob(bytes: Uint8Array, type: string): Blob {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return new Blob([buffer], { type });
}
function removeControlCharacters(value: string): string {
return Array.from(value)
.filter((character) => {
const code = character.charCodeAt(0);
return code > 31 && code !== 127;
})
.join('');
}
function safeZipEntryName(filename: string, fallback: string): string {
const cleaned = removeControlCharacters(filename)
.replace(/[\\/]+/g, '_')
.trim();
return cleaned.length > 0 ? cleaned : fallback;
}
function uniqueZipEntryName(filename: string, usedNames: Set<string>): string {
if (!usedNames.has(filename)) {
usedNames.add(filename);
return filename;
}
const dotIndex = filename.lastIndexOf('.');
const hasExtension = dotIndex > 0;
const base = hasExtension ? filename.slice(0, dotIndex) : filename;
const extension = hasExtension ? filename.slice(dotIndex) : '';
let counter = 2;
let candidate = `${base}_${counter}${extension}`;
while (usedNames.has(candidate)) {
counter += 1;
candidate = `${base}_${counter}${extension}`;
}
usedNames.add(candidate);
return candidate;
}
export function createSplitZipFilename(pdfName: string): string {
const baseName = pdfName.replace(/\.pdf$/i, '').trim() || 'document';
return `${baseName}_split_pages.zip`;
}
export async function createSplitResultsZip(
results: SplitResult[]
): Promise<Blob> {
if (results.length === 0) {
throw new Error('Cannot create a ZIP archive without split results.');
}
const usedNames = new Set<string>();
const entries: Record<string, Uint8Array> = {};
for (const result of results) {
const fallback = `page_${String(result.pageIndex + 1).padStart(3, '0')}.pdf`;
const entryName = uniqueZipEntryName(
safeZipEntryName(result.filename, fallback),
usedNames
);
const arrayBuffer = await result.blob.arrayBuffer();
entries[entryName] = new Uint8Array(arrayBuffer);
}
const zippedBytes = zipSync(entries, { level: 6 });
return bytesToBlob(zippedBytes, 'application/zip');
}