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

@@ -38,6 +38,10 @@ import {
} from './pdf/pdfService';
import { usePdfThumbnails } from './pdf/usePdfThumbnails';
import { usePdfGeneratedOutputs } from './hooks/usePdfGeneratedOutputs';
import {
createSplitResultsZip,
createSplitZipFilename,
} from './pdf/pdfZipService';
import {
createSelectionPdfName,
createSelectionWorkspaceName,
@@ -85,6 +89,7 @@ const App: React.FC = () => {
const {
splitDownloads,
splitZipDownload,
subsetDownload,
exportDownload,
replaceSplitResults,
@@ -916,7 +921,11 @@ const App: React.FC = () => {
setIsBusy(true);
try {
const result = await splitIntoSinglePages(pdf);
replaceSplitResults(result);
const zipBlob = await createSplitResultsZip(result);
replaceSplitResults(result, {
blob: zipBlob,
filename: createSplitZipFilename(pdf.name),
});
} catch (e) {
console.error(e);
setError('Error while splitting PDF (see console).');
@@ -1242,6 +1251,7 @@ const App: React.FC = () => {
onOpenSelectionAsWorkspace={handleOpenSelectionAsWorkspace}
onExportReordered={handleExportReordered}
splitDownloads={splitDownloads}
splitZipDownload={splitZipDownload}
subsetDownload={subsetDownload}
exportDownload={exportDownload}
/>

View File

@@ -16,6 +16,7 @@ interface ActionsPanelProps {
onExportReordered: () => void;
splitDownloads: SplitPdfDownload[];
splitZipDownload: PdfDownload | null;
subsetDownload: PdfDownload | null;
exportDownload: PdfDownload | null;
}
@@ -29,6 +30,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
onOpenSelectionAsWorkspace,
onExportReordered,
splitDownloads,
splitZipDownload,
subsetDownload,
exportDownload,
}) => {
@@ -132,7 +134,18 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
{splitDownloads.length > 0 && (
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
<strong>Single-page PDFs:</strong>
<div>
{splitZipDownload && (
<div style={{ marginTop: '0.25rem' }}>
<a
className="download-link"
href={splitZipDownload.url}
download={splitZipDownload.filename}
>
Download all as ZIP ({splitDownloads.length} files)
</a>
</div>
)}
<div style={{ marginTop: '0.25rem' }}>
{splitDownloads.map((download) => (
<a
key={download.id}

View File

@@ -49,11 +49,15 @@ const tutorialSteps = [
body: 'Extract selected pages when you only need a download. Open the selection as a new workspace when you want to continue working on that subset.',
},
{
title: '5. Save your workspace or export a PDF',
title: '5. Split and download results',
body: 'Splitting creates individual one-page PDF downloads and a ZIP archive that contains all generated page files.',
},
{
title: '6. Save your workspace or export a PDF',
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
},
{
title: '6. Use history deliberately',
title: '7. Use history deliberately',
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
},
];

View File

@@ -11,6 +11,11 @@ export interface SplitPdfDownload extends PdfDownload {
pageIndex: number;
}
export interface PdfBlobResult {
blob: Blob;
filename: string;
}
function revokeDownload(download: PdfDownload | null): void {
if (download) {
URL.revokeObjectURL(download.url);
@@ -37,30 +42,51 @@ export function usePdfGeneratedOutputs() {
const [exportDownload, setExportDownload] = useState<PdfDownload | null>(
null
);
const [splitZipDownload, setSplitZipDownload] = useState<PdfDownload | null>(
null
);
const splitDownloadsRef = useRef<SplitPdfDownload[]>([]);
const subsetDownloadRef = useRef<PdfDownload | null>(null);
const exportDownloadRef = useRef<PdfDownload | null>(null);
const splitZipDownloadRef = useRef<PdfDownload | null>(null);
const replaceSplitResults = useCallback((results: SplitResult[]) => {
const nextDownloads: SplitPdfDownload[] = results.map((result) => ({
...createDownload(
`split-${result.pageIndex}-${result.filename}`,
result.filename,
result.blob
),
pageIndex: result.pageIndex,
}));
const replaceSplitResults = useCallback(
(results: SplitResult[], zipResult?: PdfBlobResult) => {
const nextDownloads: SplitPdfDownload[] = results.map((result) => ({
...createDownload(
`split-${result.pageIndex}-${result.filename}`,
result.filename,
result.blob
),
pageIndex: result.pageIndex,
}));
revokeDownloads(splitDownloadsRef.current);
splitDownloadsRef.current = nextDownloads;
setSplitDownloads(nextDownloads);
}, []);
const nextZipDownload = zipResult
? createDownload('split-zip', zipResult.filename, zipResult.blob)
: null;
revokeDownloads(splitDownloadsRef.current);
revokeDownload(splitZipDownloadRef.current);
splitDownloadsRef.current = nextDownloads;
splitZipDownloadRef.current = nextZipDownload;
setSplitDownloads(nextDownloads);
setSplitZipDownload(nextZipDownload);
},
[]
);
const clearSplitResults = useCallback(() => {
revokeDownloads(splitDownloadsRef.current);
revokeDownload(splitZipDownloadRef.current);
splitDownloadsRef.current = [];
splitZipDownloadRef.current = null;
setSplitDownloads([]);
setSplitZipDownload(null);
}, []);
const replaceSubsetResult = useCallback((blob: Blob, filename: string) => {
@@ -95,14 +121,17 @@ export function usePdfGeneratedOutputs() {
revokeDownloads(splitDownloadsRef.current);
revokeDownload(subsetDownloadRef.current);
revokeDownload(exportDownloadRef.current);
revokeDownload(splitZipDownloadRef.current);
splitDownloadsRef.current = [];
subsetDownloadRef.current = null;
exportDownloadRef.current = null;
splitZipDownloadRef.current = null;
setSplitDownloads([]);
setSubsetDownload(null);
setExportDownload(null);
setSplitZipDownload(null);
}, []);
useEffect(() => {
@@ -110,6 +139,7 @@ export function usePdfGeneratedOutputs() {
revokeDownloads(splitDownloadsRef.current);
revokeDownload(subsetDownloadRef.current);
revokeDownload(exportDownloadRef.current);
revokeDownload(splitZipDownloadRef.current);
};
}, []);
@@ -117,6 +147,7 @@ export function usePdfGeneratedOutputs() {
splitDownloads,
subsetDownload,
exportDownload,
splitZipDownload,
replaceSplitResults,
clearSplitResults,
replaceSubsetResult,

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');
}

View File

@@ -1 +1 @@
export const APP_VERSION = '0.3.0';
export const APP_VERSION = '0.3.1';