feat(pdf-tools): add alternate merge tool for mixing pdf pages

Implement new tool that allows users to combine PDFs by alternating pages from each document. Includes UI components, logic for processing, and test coverage.

- Add new tool to configuration arrays and categories
- Create UI with drag-and-drop file ordering
- Implement core logic for alternating pages
- Add comprehensive unit tests
This commit is contained in:
abdullahalam123
2025-10-16 12:35:43 +05:30
parent a82148c253
commit 48baad9bf9
8 changed files with 291 additions and 2 deletions

View File

@@ -14,4 +14,5 @@ export const simpleTools = [
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',
'alternate-merge'
];

View File

@@ -67,6 +67,7 @@ export const categories = [
tools: [
{ 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: '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: '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.' },

View File

@@ -261,6 +261,8 @@ function handleMultiFileUpload(toolId) {
if (toolId === 'merge') {
toolLogic.merge.setup();
} else if (toolId === 'alternate-merge') {
toolLogic['alternate-merge'].setup();
} else if (toolId === 'image-to-pdf') {
const imageList = document.getElementById('image-list');
imageList.textContent = ''; // Clear safely
@@ -394,7 +396,7 @@ export function setupFileInputHandler(toolId) {
const fileControls = document.getElementById('file-controls');
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 => {
const el = document.getElementById(id);
if (el) el.textContent = '';

View 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();
}
}

View File

@@ -54,6 +54,7 @@ import { setupCropperTool } from './cropper.js';
import { processAndDownloadForm, setupFormFiller } from './form-filler.js';
import { posterize, setupPosterizeTool } from './posterize.js';
import { removeBlankPages, setupRemoveBlankPagesTool } from './remove-blank-pages.js';
import { alternateMerge, setupAlternateMergeTool } from './alternate-merge.js';
export const toolLogic = {
merge: { process: merge, setup: setupMergeTool },
@@ -111,4 +112,5 @@ export const toolLogic = {
'form-filler': { process: processAndDownloadForm, setup: setupFormFiller},
'posterize': { process: posterize, setup: setupPosterizeTool },
'remove-blank-pages': { process: removeBlankPages, setup: setupRemoveBlankPagesTool },
'alternate-merge': { process: alternateMerge, setup: setupAlternateMergeTool },
};

View File

@@ -1819,4 +1819,22 @@ posterize: () => `
</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>
`,
};