Merge pull request #21 from alam00000/alternate-merge
feat(pdf-tools): add alternate merge tool for mixing pdf pages
This commit is contained in:
@@ -14,4 +14,5 @@ export const simpleTools = [
|
|||||||
|
|
||||||
export const multiFileTools = [
|
export const multiFileTools = [
|
||||||
'merge', 'pdf-to-zip', 'jpg-to-pdf', 'png-to-pdf', 'webp-to-pdf', 'image-to-pdf', 'svg-to-pdf', 'bmp-to-pdf', 'heic-to-pdf', 'tiff-to-pdf',
|
'merge', 'pdf-to-zip', 'jpg-to-pdf', 'png-to-pdf', 'webp-to-pdf', 'image-to-pdf', 'svg-to-pdf', 'bmp-to-pdf', 'heic-to-pdf', 'tiff-to-pdf',
|
||||||
|
'alternate-merge'
|
||||||
];
|
];
|
||||||
@@ -67,6 +67,7 @@ export const categories = [
|
|||||||
tools: [
|
tools: [
|
||||||
{ id: 'ocr-pdf', name: 'OCR PDF', icon: 'scan-text', subtitle: 'Make a PDF searchable and copyable.' },
|
{ id: 'ocr-pdf', name: 'OCR PDF', icon: 'scan-text', subtitle: 'Make a PDF searchable and copyable.' },
|
||||||
{ id: 'merge', name: 'Merge PDF', icon: 'combine', subtitle: 'Combine multiple PDFs into one file.' },
|
{ id: 'merge', name: 'Merge PDF', icon: 'combine', subtitle: 'Combine multiple PDFs into one file.' },
|
||||||
|
{ id: 'alternate-merge', name: 'Alternate & Mix Pages', icon: 'shuffle', subtitle: 'Combine PDFs by alternating pages from each.' },
|
||||||
{ id: 'organize', name: 'Organize PDF', icon: 'grip', subtitle: 'Reorder pages by dragging and dropping.' },
|
{ id: 'organize', name: 'Organize PDF', icon: 'grip', subtitle: 'Reorder pages by dragging and dropping.' },
|
||||||
{ id: 'duplicate-organize', name: 'Duplicate & Organize', icon: 'files', subtitle: 'Duplicate, reorder, and delete pages.' },
|
{ id: 'duplicate-organize', name: 'Duplicate & Organize', icon: 'files', subtitle: 'Duplicate, reorder, and delete pages.' },
|
||||||
{ id: 'split', name: 'Split PDF', icon: 'scissors', subtitle: 'Extract a range of pages into a new PDF.' },
|
{ id: 'split', name: 'Split PDF', icon: 'scissors', subtitle: 'Extract a range of pages into a new PDF.' },
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ function handleMultiFileUpload(toolId) {
|
|||||||
|
|
||||||
if (toolId === 'merge') {
|
if (toolId === 'merge') {
|
||||||
toolLogic.merge.setup();
|
toolLogic.merge.setup();
|
||||||
|
} else if (toolId === 'alternate-merge') {
|
||||||
|
toolLogic['alternate-merge'].setup();
|
||||||
} else if (toolId === 'image-to-pdf') {
|
} else if (toolId === 'image-to-pdf') {
|
||||||
const imageList = document.getElementById('image-list');
|
const imageList = document.getElementById('image-list');
|
||||||
imageList.textContent = ''; // Clear safely
|
imageList.textContent = ''; // Clear safely
|
||||||
@@ -394,7 +396,7 @@ export function setupFileInputHandler(toolId) {
|
|||||||
const fileControls = document.getElementById('file-controls');
|
const fileControls = document.getElementById('file-controls');
|
||||||
if (fileControls) fileControls.classList.add('hidden');
|
if (fileControls) fileControls.classList.add('hidden');
|
||||||
|
|
||||||
const toolSpecificUI = ['file-list', 'page-merge-preview', 'image-list'];
|
const toolSpecificUI = ['file-list', 'page-merge-preview', 'image-list', 'alternate-file-list'];
|
||||||
toolSpecificUI.forEach(id => {
|
toolSpecificUI.forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = '';
|
if (el) el.textContent = '';
|
||||||
|
|||||||
107
src/js/logic/alternate-merge.ts
Normal file
107
src/js/logic/alternate-merge.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||||
|
import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
|
|
||||||
|
const alternateMergeState = {
|
||||||
|
pdfDocs: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setupAlternateMergeTool() {
|
||||||
|
const optionsDiv = document.getElementById('alternate-merge-options');
|
||||||
|
const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
|
||||||
|
const fileList = document.getElementById('alternate-file-list');
|
||||||
|
|
||||||
|
if (!optionsDiv || !processBtn || !fileList) return;
|
||||||
|
|
||||||
|
optionsDiv.classList.remove('hidden');
|
||||||
|
processBtn.disabled = false;
|
||||||
|
processBtn.onclick = alternateMerge;
|
||||||
|
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
alternateMergeState.pdfDocs = {};
|
||||||
|
|
||||||
|
showLoader('Loading PDF documents...');
|
||||||
|
try {
|
||||||
|
for (const file of state.files) {
|
||||||
|
const pdfBytes = await readFileAsArrayBuffer(file);
|
||||||
|
alternateMergeState.pdfDocs[file.name] = await PDFDocument.load(pdfBytes as ArrayBuffer, {
|
||||||
|
ignoreEncryption: true
|
||||||
|
});
|
||||||
|
const pageCount = alternateMergeState.pdfDocs[file.name].getPageCount();
|
||||||
|
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'bg-gray-700 p-3 rounded-lg border border-gray-600 flex items-center justify-between';
|
||||||
|
li.dataset.fileName = file.name;
|
||||||
|
|
||||||
|
const infoDiv = document.createElement('div');
|
||||||
|
infoDiv.className = 'flex items-center gap-2 truncate';
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'truncate font-medium text-white';
|
||||||
|
nameSpan.textContent = file.name;
|
||||||
|
|
||||||
|
const pagesSpan = document.createElement('span');
|
||||||
|
pagesSpan.className = 'text-sm text-gray-400 flex-shrink-0';
|
||||||
|
pagesSpan.textContent = `(${pageCount} pages)`;
|
||||||
|
|
||||||
|
infoDiv.append(nameSpan, pagesSpan);
|
||||||
|
|
||||||
|
const dragHandle = document.createElement('div');
|
||||||
|
dragHandle.className = 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded';
|
||||||
|
dragHandle.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="5" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="19" r="1"/></svg>`;
|
||||||
|
|
||||||
|
li.append(infoDiv, dragHandle);
|
||||||
|
fileList.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
Sortable.create(fileList, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('Error', 'Failed to load one or more PDF files. They may be corrupted or password-protected.');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function alternateMerge() {
|
||||||
|
if (Object.keys(alternateMergeState.pdfDocs).length < 2) {
|
||||||
|
showAlert('Not Enough Files', 'Please upload at least two PDF files to alternate and mix.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoader('Alternating and mixing pages...');
|
||||||
|
try {
|
||||||
|
const newPdfDoc = await PDFDocument.create();
|
||||||
|
const fileList = document.getElementById('alternate-file-list');
|
||||||
|
const sortedFileNames = Array.from(fileList.children).map(li => (li as HTMLElement).dataset.fileName);
|
||||||
|
|
||||||
|
const loadedDocs = sortedFileNames.map(name => alternateMergeState.pdfDocs[name]);
|
||||||
|
const pageCounts = loadedDocs.map(doc => doc.getPageCount());
|
||||||
|
const maxPages = Math.max(...pageCounts);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxPages; i++) {
|
||||||
|
for (const doc of loadedDocs) {
|
||||||
|
if (i < doc.getPageCount()) {
|
||||||
|
const [copiedPage] = await newPdfDoc.copyPages(doc, [i]);
|
||||||
|
newPdfDoc.addPage(copiedPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedPdfBytes = await newPdfDoc.save();
|
||||||
|
downloadFile(new Blob([new Uint8Array(mergedPdfBytes)], { type: 'application/pdf' }), 'alternated-mixed.pdf');
|
||||||
|
showAlert('Success', 'PDFs have been mixed successfully!');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Alternate Merge error:', e);
|
||||||
|
showAlert('Error', 'An error occurred while mixing the PDFs.');
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ import { setupCropperTool } from './cropper.js';
|
|||||||
import { processAndDownloadForm, setupFormFiller } from './form-filler.js';
|
import { processAndDownloadForm, setupFormFiller } from './form-filler.js';
|
||||||
import { posterize, setupPosterizeTool } from './posterize.js';
|
import { posterize, setupPosterizeTool } from './posterize.js';
|
||||||
import { removeBlankPages, setupRemoveBlankPagesTool } from './remove-blank-pages.js';
|
import { removeBlankPages, setupRemoveBlankPagesTool } from './remove-blank-pages.js';
|
||||||
|
import { alternateMerge, setupAlternateMergeTool } from './alternate-merge.js';
|
||||||
|
|
||||||
export const toolLogic = {
|
export const toolLogic = {
|
||||||
merge: { process: merge, setup: setupMergeTool },
|
merge: { process: merge, setup: setupMergeTool },
|
||||||
@@ -111,4 +112,5 @@ export const toolLogic = {
|
|||||||
'form-filler': { process: processAndDownloadForm, setup: setupFormFiller},
|
'form-filler': { process: processAndDownloadForm, setup: setupFormFiller},
|
||||||
'posterize': { process: posterize, setup: setupPosterizeTool },
|
'posterize': { process: posterize, setup: setupPosterizeTool },
|
||||||
'remove-blank-pages': { process: removeBlankPages, setup: setupRemoveBlankPagesTool },
|
'remove-blank-pages': { process: removeBlankPages, setup: setupRemoveBlankPagesTool },
|
||||||
|
'alternate-merge': { process: alternateMerge, setup: setupAlternateMergeTool },
|
||||||
};
|
};
|
||||||
18
src/js/ui.ts
18
src/js/ui.ts
@@ -1819,4 +1819,22 @@ posterize: () => `
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
'alternate-merge': () => `
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-4">Alternate & Mix Pages</h2>
|
||||||
|
<p class="mb-6 text-gray-400">Combine pages from 2 or more documents, alternating between them. Drag the files to set the mixing order (e.g., Page 1 from Doc A, Page 1 from Doc B, Page 2 from Doc A, Page 2 from Doc B, etc.).</p>
|
||||||
|
${createFileInputHTML({ multiple: true, accept: 'application/pdf', showControls: true })}
|
||||||
|
|
||||||
|
<div id="alternate-merge-options" class="hidden mt-6">
|
||||||
|
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
|
||||||
|
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
|
||||||
|
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
|
||||||
|
<li>The tool will take one page from each document in the order you specify below, then repeat for the next page until all pages are used.</li>
|
||||||
|
<li>If a document runs out of pages, it will be skipped, and the tool will continue alternating with the remaining documents.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<ul id="alternate-file-list" class="space-y-2"></ul>
|
||||||
|
<button id="process-btn" class="btn-gradient w-full mt-6" disabled>Alternate & Mix PDFs</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
|
||||||
};
|
};
|
||||||
158
src/tests/alternate-merge.test.ts
Normal file
158
src/tests/alternate-merge.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { state } from '@/js/state';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
|
import * as helpers from '@/js/utils/helpers';
|
||||||
|
import * as ui from '@/js/ui';
|
||||||
|
import { setupAlternateMergeTool, alternateMerge } from '@/js/logic/alternate-merge';
|
||||||
|
|
||||||
|
vi.mock('pdf-lib', () => ({
|
||||||
|
PDFDocument: {
|
||||||
|
create: vi.fn(),
|
||||||
|
load: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sortablejs', () => ({
|
||||||
|
default: { create: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/js/utils/helpers', () => ({
|
||||||
|
readFileAsArrayBuffer: vi.fn(),
|
||||||
|
downloadFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/js/ui', () => ({
|
||||||
|
showLoader: vi.fn(),
|
||||||
|
hideLoader: vi.fn(),
|
||||||
|
showAlert: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Alternate Merge Tool', () => {
|
||||||
|
let mockPdfDoc1: any;
|
||||||
|
let mockPdfDoc2: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="alternate-merge-options" class="hidden"></div>
|
||||||
|
<button id="process-btn"></button>
|
||||||
|
<ul id="alternate-file-list"></ul>
|
||||||
|
`;
|
||||||
|
|
||||||
|
state.files = [
|
||||||
|
new File(['dummy1'], 'file1.pdf'),
|
||||||
|
new File(['dummy2'], 'file2.pdf'),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockPdfDoc1 = { getPageCount: vi.fn(() => 2) };
|
||||||
|
mockPdfDoc2 = { getPageCount: vi.fn(() => 3) };
|
||||||
|
|
||||||
|
vi.mocked(helpers.readFileAsArrayBuffer).mockResolvedValue(new ArrayBuffer(8));
|
||||||
|
vi.mocked(PDFDocument.load)
|
||||||
|
.mockResolvedValueOnce(mockPdfDoc1)
|
||||||
|
.mockResolvedValueOnce(mockPdfDoc2);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupAlternateMergeTool()', () => {
|
||||||
|
it('should initialize UI and load PDF files', async () => {
|
||||||
|
await setupAlternateMergeTool();
|
||||||
|
|
||||||
|
expect(ui.showLoader).toHaveBeenCalledWith('Loading PDF documents...');
|
||||||
|
expect(ui.hideLoader).toHaveBeenCalled();
|
||||||
|
expect(PDFDocument.load).toHaveBeenCalledTimes(2);
|
||||||
|
expect(document.querySelectorAll('#alternate-file-list li').length).toBe(2);
|
||||||
|
expect(Sortable.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show alert on load failure', async () => {
|
||||||
|
vi.mocked(PDFDocument.load).mockReset();
|
||||||
|
vi.mocked(PDFDocument.load).mockRejectedValueOnce(new Error('bad pdf'));
|
||||||
|
|
||||||
|
await setupAlternateMergeTool();
|
||||||
|
|
||||||
|
expect(ui.showAlert).toHaveBeenCalledWith(
|
||||||
|
'Error',
|
||||||
|
expect.stringContaining('Failed to load one or more PDF files')
|
||||||
|
);
|
||||||
|
expect(ui.hideLoader).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('alternateMerge()', () => {
|
||||||
|
it('should show alert if less than 2 PDFs loaded', async () => {
|
||||||
|
// Setup with only 1 file - need to call setup first to populate internal state
|
||||||
|
state.files = [new File(['dummy1'], 'file1.pdf')];
|
||||||
|
vi.mocked(PDFDocument.load).mockReset();
|
||||||
|
vi.mocked(PDFDocument.load).mockResolvedValueOnce(mockPdfDoc1);
|
||||||
|
|
||||||
|
await setupAlternateMergeTool();
|
||||||
|
vi.clearAllMocks(); // Clear the setup calls
|
||||||
|
|
||||||
|
await alternateMerge();
|
||||||
|
|
||||||
|
expect(ui.showAlert).toHaveBeenCalledWith(
|
||||||
|
'Not Enough Files',
|
||||||
|
expect.stringContaining('Please upload at least two PDF files')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge pages alternately and download file', async () => {
|
||||||
|
// First setup the tool to populate internal state
|
||||||
|
await setupAlternateMergeTool();
|
||||||
|
vi.clearAllMocks(); // Clear setup calls
|
||||||
|
|
||||||
|
const mockCopyPages = vi.fn(() =>
|
||||||
|
Promise.resolve([{ page: 'mockPage' }] as any)
|
||||||
|
);
|
||||||
|
const mockAddPage = vi.fn();
|
||||||
|
const mockSave = vi.fn(() => Promise.resolve(new Uint8Array([1, 2, 3])));
|
||||||
|
|
||||||
|
vi.mocked(PDFDocument.create).mockResolvedValue({
|
||||||
|
copyPages: mockCopyPages,
|
||||||
|
addPage: mockAddPage,
|
||||||
|
save: mockSave,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const fileList = document.getElementById('alternate-file-list')!;
|
||||||
|
// The list should already be populated by setupAlternateMergeTool
|
||||||
|
// But ensure it has the correct structure
|
||||||
|
fileList.innerHTML = `
|
||||||
|
<li data-file-name="file1.pdf"></li>
|
||||||
|
<li data-file-name="file2.pdf"></li>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await alternateMerge();
|
||||||
|
|
||||||
|
expect(ui.showLoader).toHaveBeenCalledWith(expect.stringContaining('Alternating'));
|
||||||
|
expect(mockCopyPages).toHaveBeenCalled();
|
||||||
|
expect(mockAddPage).toHaveBeenCalled();
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(helpers.downloadFile).toHaveBeenCalled();
|
||||||
|
expect(ui.showAlert).toHaveBeenCalledWith('Success', expect.stringContaining('mixed successfully'));
|
||||||
|
expect(ui.hideLoader).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show alert on merge error', async () => {
|
||||||
|
// Setup the tool first to populate internal state with 2 PDFs
|
||||||
|
await setupAlternateMergeTool();
|
||||||
|
vi.clearAllMocks(); // Clear setup calls
|
||||||
|
|
||||||
|
// Mock PDFDocument.create to reject
|
||||||
|
vi.mocked(PDFDocument.create).mockRejectedValue(new Error('broken'));
|
||||||
|
|
||||||
|
await alternateMerge();
|
||||||
|
|
||||||
|
expect(ui.showAlert).toHaveBeenCalledWith(
|
||||||
|
'Error',
|
||||||
|
expect.stringContaining('An error occurred while mixing')
|
||||||
|
);
|
||||||
|
expect(ui.hideLoader).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ describe('Tool Configuration Arrays', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have the correct number of tools', () => {
|
it('should have the correct number of tools', () => {
|
||||||
expect(multiFileTools).toHaveLength(10);
|
expect(multiFileTools).toHaveLength(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not contain any duplicate tools', () => {
|
it('should not contain any duplicate tools', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user