Merge remote-tracking branch 'origin/main' into pdf-to-image-direct-image
This commit is contained in:
@@ -2,7 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui';
|
||||
import { readFileAsArrayBuffer, downloadFile } from '../utils/helpers';
|
||||
import { state } from '../state';
|
||||
|
||||
const worker = new Worker('/workers/add-attachments.worker.js');
|
||||
const worker = new Worker(import.meta.env.BASE_URL + 'workers/add-attachments.worker.js');
|
||||
|
||||
let attachments: File[] = [];
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ if (saveStampedBtn) {
|
||||
|
||||
if (backToToolsBtn) {
|
||||
backToToolsBtn.addEventListener('click', () => {
|
||||
window.location.href = '/'
|
||||
window.location.href = import.meta.env.BASE_URL
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const alternateMergeState: AlternateMergeState = {
|
||||
pdfBytes: {},
|
||||
};
|
||||
|
||||
const alternateMergeWorker = new Worker('/workers/alternate-merge.worker.js');
|
||||
const alternateMergeWorker = new Worker(import.meta.env.BASE_URL + 'workers/alternate-merge.worker.js');
|
||||
|
||||
export async function setupAlternateMergeTool() {
|
||||
const optionsDiv = document.getElementById('alternate-merge-options');
|
||||
|
||||
@@ -1957,13 +1957,13 @@ async function extractExistingBookmarks(doc) {
|
||||
// Back to tools button
|
||||
if (backToToolsBtn) {
|
||||
backToToolsBtn.addEventListener('click', () => {
|
||||
window.location.href = '/';
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
window.location.href = '/';
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
562
src/js/logic/compress-pdf-page.ts
Normal file
562
src/js/logic/compress-pdf-page.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import {
|
||||
downloadFile,
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { PDFDocument, PDFName, PDFDict, PDFStream, PDFNumber } from 'pdf-lib';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
function dataUrlToBytes(dataUrl: any) {
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function performSmartCompression(arrayBuffer: any, settings: any) {
|
||||
const pdfDoc = await PDFDocument.load(arrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
if (settings.removeMetadata) {
|
||||
try {
|
||||
pdfDoc.setTitle('');
|
||||
pdfDoc.setAuthor('');
|
||||
pdfDoc.setSubject('');
|
||||
pdfDoc.setKeywords([]);
|
||||
pdfDoc.setCreator('');
|
||||
pdfDoc.setProducer('');
|
||||
} catch (e) {
|
||||
console.warn('Could not remove metadata:', e);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const resources = page.node.Resources();
|
||||
if (!resources) continue;
|
||||
|
||||
const xobjects = resources.lookup(PDFName.of('XObject'));
|
||||
if (!(xobjects instanceof PDFDict)) continue;
|
||||
|
||||
for (const [key, value] of xobjects.entries()) {
|
||||
const stream = pdfDoc.context.lookup(value);
|
||||
if (
|
||||
!(stream instanceof PDFStream) ||
|
||||
stream.dict.get(PDFName.of('Subtype')) !== PDFName.of('Image')
|
||||
)
|
||||
continue;
|
||||
|
||||
try {
|
||||
const imageBytes = stream.getContents();
|
||||
if (imageBytes.length < settings.skipSize) continue;
|
||||
|
||||
const width =
|
||||
stream.dict.get(PDFName.of('Width')) instanceof PDFNumber
|
||||
? (stream.dict.get(PDFName.of('Width')) as PDFNumber).asNumber()
|
||||
: 0;
|
||||
const height =
|
||||
stream.dict.get(PDFName.of('Height')) instanceof PDFNumber
|
||||
? (stream.dict.get(PDFName.of('Height')) as PDFNumber).asNumber()
|
||||
: 0;
|
||||
const bitsPerComponent =
|
||||
stream.dict.get(PDFName.of('BitsPerComponent')) instanceof PDFNumber
|
||||
? (
|
||||
stream.dict.get(PDFName.of('BitsPerComponent')) as PDFNumber
|
||||
).asNumber()
|
||||
: 8;
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
let newWidth = width;
|
||||
let newHeight = height;
|
||||
|
||||
const scaleFactor = settings.scaleFactor || 1.0;
|
||||
newWidth = Math.floor(width * scaleFactor);
|
||||
newHeight = Math.floor(height * scaleFactor);
|
||||
|
||||
if (newWidth > settings.maxWidth || newHeight > settings.maxHeight) {
|
||||
const aspectRatio = newWidth / newHeight;
|
||||
if (newWidth > newHeight) {
|
||||
newWidth = Math.min(newWidth, settings.maxWidth);
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else {
|
||||
newHeight = Math.min(newHeight, settings.maxHeight);
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
const minDim = settings.minDimension || 50;
|
||||
if (newWidth < minDim || newHeight < minDim) continue;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = Math.floor(newWidth);
|
||||
canvas.height = Math.floor(newHeight);
|
||||
|
||||
const img = new Image();
|
||||
const imageUrl = URL.createObjectURL(
|
||||
new Blob([new Uint8Array(imageBytes)])
|
||||
);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
ctx.imageSmoothingEnabled = settings.smoothing !== false;
|
||||
ctx.imageSmoothingQuality = settings.smoothingQuality || 'medium';
|
||||
|
||||
if (settings.grayscale) {
|
||||
ctx.filter = 'grayscale(100%)';
|
||||
} else if (settings.contrast) {
|
||||
ctx.filter = `contrast(${settings.contrast}) brightness(${settings.brightness || 1})`;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
let bestBytes = null;
|
||||
let bestSize = imageBytes.length;
|
||||
|
||||
const jpegDataUrl = canvas.toDataURL('image/jpeg', settings.quality);
|
||||
const jpegBytes = dataUrlToBytes(jpegDataUrl);
|
||||
if (jpegBytes.length < bestSize) {
|
||||
bestBytes = jpegBytes;
|
||||
bestSize = jpegBytes.length;
|
||||
}
|
||||
|
||||
if (settings.tryWebP) {
|
||||
try {
|
||||
const webpDataUrl = canvas.toDataURL(
|
||||
'image/webp',
|
||||
settings.quality
|
||||
);
|
||||
const webpBytes = dataUrlToBytes(webpDataUrl);
|
||||
if (webpBytes.length < bestSize) {
|
||||
bestBytes = webpBytes;
|
||||
bestSize = webpBytes.length;
|
||||
}
|
||||
} catch (e) {
|
||||
/* WebP not supported */
|
||||
}
|
||||
}
|
||||
|
||||
if (bestBytes && bestSize < imageBytes.length * settings.threshold) {
|
||||
(stream as any).contents = bestBytes;
|
||||
stream.dict.set(PDFName.of('Length'), PDFNumber.of(bestSize));
|
||||
stream.dict.set(PDFName.of('Width'), PDFNumber.of(canvas.width));
|
||||
stream.dict.set(PDFName.of('Height'), PDFNumber.of(canvas.height));
|
||||
stream.dict.set(PDFName.of('Filter'), PDFName.of('DCTDecode'));
|
||||
stream.dict.delete(PDFName.of('DecodeParms'));
|
||||
stream.dict.set(PDFName.of('BitsPerComponent'), PDFNumber.of(8));
|
||||
|
||||
if (settings.grayscale) {
|
||||
stream.dict.set(
|
||||
PDFName.of('ColorSpace'),
|
||||
PDFName.of('DeviceGray')
|
||||
);
|
||||
}
|
||||
}
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Skipping an uncompressible image in smart mode:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveOptions = {
|
||||
useObjectStreams: settings.useObjectStreams !== false,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: settings.objectsPerTick || 50,
|
||||
};
|
||||
|
||||
return await pdfDoc.save(saveOptions);
|
||||
}
|
||||
|
||||
async function performLegacyCompression(arrayBuffer: any, settings: any) {
|
||||
const pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
|
||||
for (let i = 1; i <= pdfJsDoc.numPages; i++) {
|
||||
const page = await pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: settings.scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas: canvas })
|
||||
.promise;
|
||||
|
||||
const jpegBlob = await new Promise((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', settings.quality)
|
||||
);
|
||||
const jpegBytes = await (jpegBlob as Blob).arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
}
|
||||
return await newPdfDoc.save();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const compressOptions = document.getElementById('compress-options');
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
const addMoreBtn = document.getElementById('add-more-btn');
|
||||
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
const updateUI = async () => {
|
||||
if (!fileDisplayArea || !compressOptions || !processBtn || !fileControls) return;
|
||||
|
||||
if (state.files.length > 0) {
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
for (let index = 0; index < state.files.length; index++) {
|
||||
const file = state.files[index];
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSizeContainer = document.createElement('div');
|
||||
nameSizeContainer.className = 'flex items-center gap-2';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
|
||||
sizeSpan.textContent = `(${formatBytes(file.size)})`;
|
||||
|
||||
nameSizeContainer.append(nameSpan, sizeSpan);
|
||||
|
||||
const pagesSpan = document.createElement('span');
|
||||
pagesSpan.className = 'text-xs text-gray-500 mt-0.5';
|
||||
pagesSpan.textContent = 'Loading pages...';
|
||||
|
||||
infoContainer.append(nameSizeContainer, pagesSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
state.files = state.files.filter((_, i) => i !== index);
|
||||
updateUI();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
|
||||
pagesSpan.textContent = `${pdfDoc.numPages} Pages`;
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
pagesSpan.textContent = 'Could not load page count';
|
||||
}
|
||||
}
|
||||
|
||||
createIcons({ icons });
|
||||
fileControls.classList.remove('hidden');
|
||||
compressOptions.classList.remove('hidden');
|
||||
(processBtn as HTMLButtonElement).disabled = false;
|
||||
} else {
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileControls.classList.add('hidden');
|
||||
compressOptions.classList.add('hidden');
|
||||
(processBtn as HTMLButtonElement).disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
state.files = [];
|
||||
state.pdfDoc = null;
|
||||
|
||||
const compressionLevel = document.getElementById('compression-level') as HTMLSelectElement;
|
||||
if (compressionLevel) compressionLevel.value = 'balanced';
|
||||
|
||||
const compressionAlgorithm = document.getElementById('compression-algorithm') as HTMLSelectElement;
|
||||
if (compressionAlgorithm) compressionAlgorithm.value = 'vector';
|
||||
|
||||
updateUI();
|
||||
};
|
||||
|
||||
const compress = async () => {
|
||||
const level = (document.getElementById('compression-level') as HTMLSelectElement).value;
|
||||
const algorithm = (document.getElementById('compression-algorithm') as HTMLSelectElement).value;
|
||||
|
||||
const settings = {
|
||||
balanced: {
|
||||
smart: {
|
||||
quality: 0.5,
|
||||
threshold: 0.95,
|
||||
maxWidth: 1800,
|
||||
maxHeight: 1800,
|
||||
skipSize: 3000,
|
||||
},
|
||||
legacy: { scale: 1.5, quality: 0.6 },
|
||||
},
|
||||
'high-quality': {
|
||||
smart: {
|
||||
quality: 0.7,
|
||||
threshold: 0.98,
|
||||
maxWidth: 2500,
|
||||
maxHeight: 2500,
|
||||
skipSize: 5000,
|
||||
},
|
||||
legacy: { scale: 2.0, quality: 0.9 },
|
||||
},
|
||||
'small-size': {
|
||||
smart: {
|
||||
quality: 0.3,
|
||||
threshold: 0.95,
|
||||
maxWidth: 1200,
|
||||
maxHeight: 1200,
|
||||
skipSize: 2000,
|
||||
},
|
||||
legacy: { scale: 1.2, quality: 0.4 },
|
||||
},
|
||||
extreme: {
|
||||
smart: {
|
||||
quality: 0.1,
|
||||
threshold: 0.95,
|
||||
maxWidth: 1000,
|
||||
maxHeight: 1000,
|
||||
skipSize: 1000,
|
||||
},
|
||||
legacy: { scale: 1.0, quality: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const smartSettings = { ...settings[level].smart, removeMetadata: true };
|
||||
const legacySettings = settings[level].legacy;
|
||||
|
||||
try {
|
||||
if (state.files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one PDF file.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.files.length === 1) {
|
||||
const originalFile = state.files[0];
|
||||
const arrayBuffer = await readFileAsArrayBuffer(originalFile);
|
||||
|
||||
let resultBytes;
|
||||
let usedMethod;
|
||||
|
||||
if (algorithm === 'vector') {
|
||||
showLoader('Running Vector (Smart) compression...');
|
||||
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
|
||||
usedMethod = 'Vector';
|
||||
} else if (algorithm === 'photon') {
|
||||
showLoader('Running Photon (Rasterize) compression...');
|
||||
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
|
||||
usedMethod = 'Photon';
|
||||
} else {
|
||||
showLoader('Running Automatic (Vector first)...');
|
||||
const vectorResultBytes = await performSmartCompression(
|
||||
arrayBuffer,
|
||||
smartSettings
|
||||
);
|
||||
|
||||
if (vectorResultBytes.length < originalFile.size) {
|
||||
resultBytes = vectorResultBytes;
|
||||
usedMethod = 'Vector (Automatic)';
|
||||
} else {
|
||||
showAlert('Vector failed to reduce size. Trying Photon...', 'info');
|
||||
showLoader('Running Automatic (Photon fallback)...');
|
||||
resultBytes = await performLegacyCompression(
|
||||
arrayBuffer,
|
||||
legacySettings
|
||||
);
|
||||
usedMethod = 'Photon (Automatic)';
|
||||
}
|
||||
}
|
||||
|
||||
const originalSize = formatBytes(originalFile.size);
|
||||
const compressedSize = formatBytes(resultBytes.length);
|
||||
const savings = originalFile.size - resultBytes.length;
|
||||
const savingsPercent =
|
||||
savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
|
||||
|
||||
downloadFile(
|
||||
new Blob([resultBytes], { type: 'application/pdf' }),
|
||||
'compressed-final.pdf'
|
||||
);
|
||||
|
||||
hideLoader();
|
||||
|
||||
if (savings > 0) {
|
||||
showAlert(
|
||||
'Compression Complete',
|
||||
`Method: ${usedMethod}. File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`,
|
||||
'success',
|
||||
() => resetState()
|
||||
);
|
||||
} else {
|
||||
showAlert(
|
||||
'Compression Finished',
|
||||
`Method: ${usedMethod}. Could not reduce file size. Original: ${originalSize}, New: ${compressedSize}.`,
|
||||
'warning',
|
||||
() => resetState()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showLoader('Compressing multiple PDFs...');
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
let totalOriginalSize = 0;
|
||||
let totalCompressedSize = 0;
|
||||
|
||||
for (let i = 0; i < state.files.length; i++) {
|
||||
const file = state.files[i];
|
||||
showLoader(`Compressing ${i + 1}/${state.files.length}: ${file.name}...`);
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
totalOriginalSize += file.size;
|
||||
|
||||
let resultBytes;
|
||||
if (algorithm === 'vector') {
|
||||
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
|
||||
} else if (algorithm === 'photon') {
|
||||
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
|
||||
} else {
|
||||
const vectorResultBytes = await performSmartCompression(
|
||||
arrayBuffer,
|
||||
smartSettings
|
||||
);
|
||||
resultBytes = vectorResultBytes.length < file.size
|
||||
? vectorResultBytes
|
||||
: await performLegacyCompression(arrayBuffer, legacySettings);
|
||||
}
|
||||
|
||||
totalCompressedSize += resultBytes.length;
|
||||
const baseName = file.name.replace(/\.pdf$/i, '');
|
||||
zip.file(`${baseName}_compressed.pdf`, resultBytes);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const totalSavings = totalOriginalSize - totalCompressedSize;
|
||||
const totalSavingsPercent =
|
||||
totalSavings > 0
|
||||
? ((totalSavings / totalOriginalSize) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
downloadFile(zipBlob, 'compressed-pdfs.zip');
|
||||
|
||||
hideLoader();
|
||||
|
||||
if (totalSavings > 0) {
|
||||
showAlert(
|
||||
'Compression Complete',
|
||||
`Compressed ${state.files.length} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${totalSavingsPercent}%).`,
|
||||
'success',
|
||||
() => resetState()
|
||||
);
|
||||
} else {
|
||||
showAlert(
|
||||
'Compression Finished',
|
||||
`Compressed ${state.files.length} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`,
|
||||
'info',
|
||||
() => resetState()
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
hideLoader();
|
||||
showAlert(
|
||||
'Error',
|
||||
`An error occurred during compression. Error: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (files && files.length > 0) {
|
||||
state.files = [...state.files, ...Array.from(files)];
|
||||
updateUI();
|
||||
}
|
||||
};
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
|
||||
if (pdfFiles.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
pdfFiles.forEach(f => dataTransfer.items.add(f));
|
||||
handleFileSelect(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearFilesBtn) {
|
||||
clearFilesBtn.addEventListener('click', () => {
|
||||
resetState();
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', compress);
|
||||
}
|
||||
});
|
||||
@@ -1,404 +0,0 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import {
|
||||
downloadFile,
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { PDFDocument, PDFName, PDFDict, PDFStream, PDFNumber } from 'pdf-lib';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
function dataUrlToBytes(dataUrl: any) {
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function performSmartCompression(arrayBuffer: any, settings: any) {
|
||||
const pdfDoc = await PDFDocument.load(arrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
if (settings.removeMetadata) {
|
||||
try {
|
||||
pdfDoc.setTitle('');
|
||||
pdfDoc.setAuthor('');
|
||||
pdfDoc.setSubject('');
|
||||
pdfDoc.setKeywords([]);
|
||||
pdfDoc.setCreator('');
|
||||
pdfDoc.setProducer('');
|
||||
} catch (e) {
|
||||
console.warn('Could not remove metadata:', e);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const resources = page.node.Resources();
|
||||
if (!resources) continue;
|
||||
|
||||
const xobjects = resources.lookup(PDFName.of('XObject'));
|
||||
if (!(xobjects instanceof PDFDict)) continue;
|
||||
|
||||
for (const [key, value] of xobjects.entries()) {
|
||||
const stream = pdfDoc.context.lookup(value);
|
||||
if (
|
||||
!(stream instanceof PDFStream) ||
|
||||
stream.dict.get(PDFName.of('Subtype')) !== PDFName.of('Image')
|
||||
)
|
||||
continue;
|
||||
|
||||
try {
|
||||
const imageBytes = stream.getContents();
|
||||
if (imageBytes.length < settings.skipSize) continue;
|
||||
|
||||
const width =
|
||||
stream.dict.get(PDFName.of('Width')) instanceof PDFNumber
|
||||
? (stream.dict.get(PDFName.of('Width')) as PDFNumber).asNumber()
|
||||
: 0;
|
||||
const height =
|
||||
stream.dict.get(PDFName.of('Height')) instanceof PDFNumber
|
||||
? (stream.dict.get(PDFName.of('Height')) as PDFNumber).asNumber()
|
||||
: 0;
|
||||
const bitsPerComponent =
|
||||
stream.dict.get(PDFName.of('BitsPerComponent')) instanceof PDFNumber
|
||||
? (
|
||||
stream.dict.get(PDFName.of('BitsPerComponent')) as PDFNumber
|
||||
).asNumber()
|
||||
: 8;
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
let newWidth = width;
|
||||
let newHeight = height;
|
||||
|
||||
const scaleFactor = settings.scaleFactor || 1.0;
|
||||
newWidth = Math.floor(width * scaleFactor);
|
||||
newHeight = Math.floor(height * scaleFactor);
|
||||
|
||||
if (newWidth > settings.maxWidth || newHeight > settings.maxHeight) {
|
||||
const aspectRatio = newWidth / newHeight;
|
||||
if (newWidth > newHeight) {
|
||||
newWidth = Math.min(newWidth, settings.maxWidth);
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else {
|
||||
newHeight = Math.min(newHeight, settings.maxHeight);
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
const minDim = settings.minDimension || 50;
|
||||
if (newWidth < minDim || newHeight < minDim) continue;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = Math.floor(newWidth);
|
||||
canvas.height = Math.floor(newHeight);
|
||||
|
||||
const img = new Image();
|
||||
const imageUrl = URL.createObjectURL(
|
||||
new Blob([new Uint8Array(imageBytes)])
|
||||
);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
ctx.imageSmoothingEnabled = settings.smoothing !== false;
|
||||
ctx.imageSmoothingQuality = settings.smoothingQuality || 'medium';
|
||||
|
||||
if (settings.grayscale) {
|
||||
ctx.filter = 'grayscale(100%)';
|
||||
} else if (settings.contrast) {
|
||||
ctx.filter = `contrast(${settings.contrast}) brightness(${settings.brightness || 1})`;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
let bestBytes = null;
|
||||
let bestSize = imageBytes.length;
|
||||
|
||||
const jpegDataUrl = canvas.toDataURL('image/jpeg', settings.quality);
|
||||
const jpegBytes = dataUrlToBytes(jpegDataUrl);
|
||||
if (jpegBytes.length < bestSize) {
|
||||
bestBytes = jpegBytes;
|
||||
bestSize = jpegBytes.length;
|
||||
}
|
||||
|
||||
if (settings.tryWebP) {
|
||||
try {
|
||||
const webpDataUrl = canvas.toDataURL(
|
||||
'image/webp',
|
||||
settings.quality
|
||||
);
|
||||
const webpBytes = dataUrlToBytes(webpDataUrl);
|
||||
if (webpBytes.length < bestSize) {
|
||||
bestBytes = webpBytes;
|
||||
bestSize = webpBytes.length;
|
||||
}
|
||||
} catch (e) {
|
||||
/* WebP not supported */
|
||||
}
|
||||
}
|
||||
|
||||
if (bestBytes && bestSize < imageBytes.length * settings.threshold) {
|
||||
(stream as any).contents = bestBytes;
|
||||
stream.dict.set(PDFName.of('Length'), PDFNumber.of(bestSize));
|
||||
stream.dict.set(PDFName.of('Width'), PDFNumber.of(canvas.width));
|
||||
stream.dict.set(PDFName.of('Height'), PDFNumber.of(canvas.height));
|
||||
stream.dict.set(PDFName.of('Filter'), PDFName.of('DCTDecode'));
|
||||
stream.dict.delete(PDFName.of('DecodeParms'));
|
||||
stream.dict.set(PDFName.of('BitsPerComponent'), PDFNumber.of(8));
|
||||
|
||||
if (settings.grayscale) {
|
||||
stream.dict.set(
|
||||
PDFName.of('ColorSpace'),
|
||||
PDFName.of('DeviceGray')
|
||||
);
|
||||
}
|
||||
}
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Skipping an uncompressible image in smart mode:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveOptions = {
|
||||
useObjectStreams: settings.useObjectStreams !== false,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: settings.objectsPerTick || 50,
|
||||
};
|
||||
|
||||
return await pdfDoc.save(saveOptions);
|
||||
}
|
||||
|
||||
async function performLegacyCompression(arrayBuffer: any, settings: any) {
|
||||
const pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
|
||||
for (let i = 1; i <= pdfJsDoc.numPages; i++) {
|
||||
const page = await pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: settings.scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas: canvas })
|
||||
.promise;
|
||||
|
||||
const jpegBlob = await new Promise((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', settings.quality)
|
||||
);
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'arrayBuffer' does not exist on type 'unk... Remove this comment to see the full error message
|
||||
const jpegBytes = await jpegBlob.arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
}
|
||||
return await newPdfDoc.save();
|
||||
}
|
||||
|
||||
export async function compress() {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const level = document.getElementById('compression-level').value;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const algorithm = document.getElementById('compression-algorithm').value;
|
||||
|
||||
const settings = {
|
||||
balanced: {
|
||||
smart: {
|
||||
quality: 0.5,
|
||||
threshold: 0.95,
|
||||
maxWidth: 1800,
|
||||
maxHeight: 1800,
|
||||
skipSize: 3000,
|
||||
},
|
||||
legacy: { scale: 1.5, quality: 0.6 },
|
||||
},
|
||||
'high-quality': {
|
||||
smart: {
|
||||
quality: 0.7,
|
||||
threshold: 0.98,
|
||||
maxWidth: 2500,
|
||||
maxHeight: 2500,
|
||||
skipSize: 5000,
|
||||
},
|
||||
legacy: { scale: 2.0, quality: 0.9 },
|
||||
},
|
||||
'small-size': {
|
||||
smart: {
|
||||
quality: 0.3,
|
||||
threshold: 0.95,
|
||||
maxWidth: 1200,
|
||||
maxHeight: 1200,
|
||||
skipSize: 2000,
|
||||
},
|
||||
legacy: { scale: 1.2, quality: 0.4 },
|
||||
},
|
||||
extreme: {
|
||||
smart: {
|
||||
quality: 0.1,
|
||||
threshold: 0.95,
|
||||
maxWidth: 1000,
|
||||
maxHeight: 1000,
|
||||
skipSize: 1000,
|
||||
},
|
||||
legacy: { scale: 1.0, quality: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const smartSettings = { ...settings[level].smart, removeMetadata: true };
|
||||
const legacySettings = settings[level].legacy;
|
||||
|
||||
try {
|
||||
if (state.files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one PDF file.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.files.length === 1) {
|
||||
const originalFile = state.files[0];
|
||||
const arrayBuffer = await readFileAsArrayBuffer(originalFile);
|
||||
|
||||
let resultBytes;
|
||||
let usedMethod;
|
||||
|
||||
if (algorithm === 'vector') {
|
||||
showLoader('Running Vector (Smart) compression...');
|
||||
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
|
||||
usedMethod = 'Vector';
|
||||
} else if (algorithm === 'photon') {
|
||||
showLoader('Running Photon (Rasterize) compression...');
|
||||
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
|
||||
usedMethod = 'Photon';
|
||||
} else {
|
||||
showLoader('Running Automatic (Vector first)...');
|
||||
const vectorResultBytes = await performSmartCompression(
|
||||
arrayBuffer,
|
||||
smartSettings
|
||||
);
|
||||
|
||||
if (vectorResultBytes.length < originalFile.size) {
|
||||
resultBytes = vectorResultBytes;
|
||||
usedMethod = 'Vector (Automatic)';
|
||||
} else {
|
||||
showAlert('Vector failed to reduce size. Trying Photon...', 'info');
|
||||
showLoader('Running Automatic (Photon fallback)...');
|
||||
resultBytes = await performLegacyCompression(
|
||||
arrayBuffer,
|
||||
legacySettings
|
||||
);
|
||||
usedMethod = 'Photon (Automatic)';
|
||||
}
|
||||
}
|
||||
|
||||
const originalSize = formatBytes(originalFile.size);
|
||||
const compressedSize = formatBytes(resultBytes.length);
|
||||
const savings = originalFile.size - resultBytes.length;
|
||||
const savingsPercent =
|
||||
savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
|
||||
|
||||
if (savings > 0) {
|
||||
showAlert(
|
||||
'Compression Complete',
|
||||
`Method: **${usedMethod}**. ` +
|
||||
`File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`
|
||||
);
|
||||
} else {
|
||||
showAlert(
|
||||
'Compression Finished',
|
||||
`Method: **${usedMethod}**. ` +
|
||||
`Could not reduce file size. Original: ${originalSize}, New: ${compressedSize}.`,
|
||||
// @ts-expect-error TS(2554) FIXME: Expected 2 arguments, but got 3.
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
downloadFile(
|
||||
new Blob([resultBytes], { type: 'application/pdf' }),
|
||||
'compressed-final.pdf'
|
||||
);
|
||||
} else {
|
||||
showLoader('Compressing multiple PDFs...');
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
let totalOriginalSize = 0;
|
||||
let totalCompressedSize = 0;
|
||||
|
||||
for (let i = 0; i < state.files.length; i++) {
|
||||
const file = state.files[i];
|
||||
showLoader(`Compressing ${i + 1}/${state.files.length}: ${file.name}...`);
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
totalOriginalSize += file.size;
|
||||
|
||||
let resultBytes;
|
||||
if (algorithm === 'vector') {
|
||||
resultBytes = await performSmartCompression(arrayBuffer, smartSettings);
|
||||
} else if (algorithm === 'photon') {
|
||||
resultBytes = await performLegacyCompression(arrayBuffer, legacySettings);
|
||||
} else {
|
||||
const vectorResultBytes = await performSmartCompression(
|
||||
arrayBuffer,
|
||||
smartSettings
|
||||
);
|
||||
resultBytes = vectorResultBytes.length < file.size
|
||||
? vectorResultBytes
|
||||
: await performLegacyCompression(arrayBuffer, legacySettings);
|
||||
}
|
||||
|
||||
totalCompressedSize += resultBytes.length;
|
||||
const baseName = file.name.replace(/\.pdf$/i, '');
|
||||
zip.file(`${baseName}_compressed.pdf`, resultBytes);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const totalSavings = totalOriginalSize - totalCompressedSize;
|
||||
const totalSavingsPercent =
|
||||
totalSavings > 0
|
||||
? ((totalSavings / totalOriginalSize) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
if (totalSavings > 0) {
|
||||
showAlert(
|
||||
'Compression Complete',
|
||||
`Compressed ${state.files.length} PDF(s). ` +
|
||||
`Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${totalSavingsPercent}%).`
|
||||
);
|
||||
} else {
|
||||
showAlert(
|
||||
'Compression Finished',
|
||||
`Compressed ${state.files.length} PDF(s). ` +
|
||||
`Total size: ${formatBytes(totalCompressedSize)}.`
|
||||
);
|
||||
}
|
||||
|
||||
downloadFile(zipBlob, 'compressed-pdfs.zip');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert(
|
||||
'Error',
|
||||
`An error occurred during compression. Error: ${e.message}`
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
const worker = new Worker('/workers/edit-attachments.worker.js');
|
||||
const worker = new Worker(import.meta.env.BASE_URL + 'workers/edit-attachments.worker.js');
|
||||
|
||||
let allAttachments: Array<{ index: number; name: string; page: number; data: Uint8Array }> = [];
|
||||
let attachmentsToRemove: Set<number> = new Set();
|
||||
|
||||
127
src/js/logic/edit-pdf-page.ts
Normal file
127
src/js/logic/edit-pdf-page.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Logic for PDF Editor Page
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
|
||||
let currentPdfUrl: string | null = null;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
}
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-indigo-500');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFiles(files);
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('click', () => {
|
||||
fileInput?.click();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
await handleFiles(input.files);
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
const file = files[0];
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Invalid File', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Loading PDF Editor...');
|
||||
|
||||
try {
|
||||
const pdfWrapper = document.getElementById('embed-pdf-wrapper');
|
||||
const pdfContainer = document.getElementById('embed-pdf-container');
|
||||
const uploader = document.getElementById('tool-uploader');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
|
||||
if (!pdfWrapper || !pdfContainer || !uploader || !dropZone) return;
|
||||
|
||||
// Hide uploader elements but keep the container
|
||||
dropZone.classList.add('hidden');
|
||||
|
||||
// Clear previous content
|
||||
pdfContainer.textContent = '';
|
||||
if (currentPdfUrl) {
|
||||
URL.revokeObjectURL(currentPdfUrl);
|
||||
}
|
||||
|
||||
// Show editor container
|
||||
pdfWrapper.classList.remove('hidden');
|
||||
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
currentPdfUrl = fileURL;
|
||||
|
||||
// Dynamically load EmbedPDF script
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.textContent = `
|
||||
import EmbedPDF from 'https://snippet.embedpdf.com/embedpdf.js';
|
||||
EmbedPDF.init({
|
||||
type: 'container',
|
||||
target: document.getElementById('embed-pdf-container'),
|
||||
src: '${fileURL}',
|
||||
});
|
||||
`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Update back button to reset state
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
if (backBtn) {
|
||||
// Clone to remove old listeners
|
||||
const newBackBtn = backBtn.cloneNode(true);
|
||||
backBtn.parentNode?.replaceChild(newBackBtn, backBtn);
|
||||
|
||||
newBackBtn.addEventListener('click', () => {
|
||||
if (currentPdfUrl) {
|
||||
URL.revokeObjectURL(currentPdfUrl);
|
||||
currentPdfUrl = null;
|
||||
}
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF Editor:', error);
|
||||
showAlert('Error', 'Failed to load the PDF Editor.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { state } from '../state.js';
|
||||
import { showAlert } from '../ui.js';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const worker = new Worker('/workers/extract-attachments.worker.js');
|
||||
const worker = new Worker(import.meta.env.BASE_URL + 'workers/extract-attachments.worker.js');
|
||||
|
||||
interface ExtractAttachmentSuccessResponse {
|
||||
status: 'success';
|
||||
|
||||
@@ -8,50 +8,14 @@ import 'pdfjs-dist/web/pdf_viewer.css'
|
||||
// Initialize PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()
|
||||
|
||||
interface FormField {
|
||||
id: string
|
||||
type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
name: string
|
||||
defaultValue: string
|
||||
fontSize: number
|
||||
alignment: 'left' | 'center' | 'right'
|
||||
textColor: string
|
||||
required: boolean
|
||||
readOnly: boolean
|
||||
tooltip: string
|
||||
combCells: number
|
||||
maxLength: number
|
||||
options?: string[]
|
||||
checked?: boolean
|
||||
exportValue?: string
|
||||
groupName?: string
|
||||
label?: string
|
||||
pageIndex: number
|
||||
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'
|
||||
actionUrl?: string
|
||||
jsScript?: string
|
||||
targetFieldName?: string
|
||||
visibilityAction?: 'show' | 'hide' | 'toggle'
|
||||
dateFormat?: string
|
||||
multiline?: boolean
|
||||
}
|
||||
import { FormField, PageData } from '../types/index.js'
|
||||
|
||||
interface PageData {
|
||||
index: number
|
||||
width: number
|
||||
height: number
|
||||
pdfPageData?: string
|
||||
}
|
||||
|
||||
let fields: FormField[] = []
|
||||
let selectedField: FormField | null = null
|
||||
let fieldCounter = 0
|
||||
let existingFieldNames: Set<string> = new Set()
|
||||
let existingRadioGroups: Set<string> = new Set()
|
||||
let existingFieldNames: Set<string> = new Set()
|
||||
let existingRadioGroups: Set<string> = new Set()
|
||||
let draggedElement: HTMLElement | null = null
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
@@ -59,7 +23,7 @@ let offsetY = 0
|
||||
let pages: PageData[] = []
|
||||
let currentPageIndex = 0
|
||||
let uploadedPdfDoc: PDFDocument | null = null
|
||||
let uploadedPdfjsDoc: any = null
|
||||
let uploadedPdfjsDoc: any = null
|
||||
let pageSize: { width: number; height: number } = { width: 612, height: 792 }
|
||||
let currentScale = 1.333
|
||||
let pdfViewerOffset = { x: 0, y: 0 }
|
||||
@@ -99,6 +63,132 @@ const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement
|
||||
const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement
|
||||
const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement
|
||||
const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement | null
|
||||
const gotoPageInput = document.getElementById('gotoPageInput') as HTMLInputElement
|
||||
const gotoPageBtn = document.getElementById('gotoPageBtn') as HTMLButtonElement
|
||||
|
||||
const gridVInput = document.getElementById('gridVInput') as HTMLInputElement
|
||||
const gridHInput = document.getElementById('gridHInput') as HTMLInputElement
|
||||
const toggleGridBtn = document.getElementById('toggleGridBtn') as HTMLButtonElement
|
||||
const enableGridCheckbox = document.getElementById('enableGridCheckbox') as HTMLInputElement
|
||||
let gridV = 2
|
||||
let gridH = 2
|
||||
let gridAlwaysVisible = false
|
||||
let gridEnabled = true
|
||||
|
||||
if (gridVInput && gridHInput) {
|
||||
gridVInput.value = '2'
|
||||
gridHInput.value = '2'
|
||||
|
||||
const updateGrid = () => {
|
||||
let v = parseInt(gridVInput.value) || 2
|
||||
let h = parseInt(gridHInput.value) || 2
|
||||
|
||||
if (v < 2) { v = 2; gridVInput.value = '2' }
|
||||
if (h < 2) { h = 2; gridHInput.value = '2' }
|
||||
if (v > 14) { v = 14; gridVInput.value = '14' }
|
||||
if (h > 14) { h = 14; gridHInput.value = '14' }
|
||||
|
||||
gridV = v
|
||||
gridH = h
|
||||
|
||||
if (gridAlwaysVisible && gridEnabled) {
|
||||
renderGrid()
|
||||
}
|
||||
}
|
||||
|
||||
gridVInput.addEventListener('input', updateGrid)
|
||||
gridHInput.addEventListener('input', updateGrid)
|
||||
}
|
||||
|
||||
if (enableGridCheckbox) {
|
||||
enableGridCheckbox.addEventListener('change', (e) => {
|
||||
gridEnabled = (e.target as HTMLInputElement).checked
|
||||
|
||||
if (!gridEnabled) {
|
||||
removeGrid()
|
||||
if (gridVInput) gridVInput.disabled = true
|
||||
if (gridHInput) gridHInput.disabled = true
|
||||
if (toggleGridBtn) toggleGridBtn.disabled = true
|
||||
} else {
|
||||
if (gridVInput) gridVInput.disabled = false
|
||||
if (gridHInput) gridHInput.disabled = false
|
||||
if (toggleGridBtn) toggleGridBtn.disabled = false
|
||||
if (gridAlwaysVisible) renderGrid()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (toggleGridBtn) {
|
||||
toggleGridBtn.addEventListener('click', () => {
|
||||
gridAlwaysVisible = !gridAlwaysVisible
|
||||
|
||||
if (gridAlwaysVisible) {
|
||||
toggleGridBtn.classList.add('bg-indigo-600')
|
||||
toggleGridBtn.classList.remove('bg-gray-600')
|
||||
if (gridEnabled) renderGrid()
|
||||
} else {
|
||||
toggleGridBtn.classList.remove('bg-indigo-600')
|
||||
toggleGridBtn.classList.add('bg-gray-600')
|
||||
removeGrid()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const existingGrid = document.getElementById('pdfGrid')
|
||||
if (existingGrid) existingGrid.remove()
|
||||
|
||||
const gridContainer = document.createElement('div')
|
||||
gridContainer.id = 'pdfGrid'
|
||||
gridContainer.className = 'absolute inset-0 pointer-events-none'
|
||||
gridContainer.style.zIndex = '1'
|
||||
|
||||
if (gridV > 0) {
|
||||
const stepX = canvas.offsetWidth / gridV
|
||||
for (let i = 0; i <= gridV; i++) {
|
||||
const line = document.createElement('div')
|
||||
line.className = 'absolute top-0 bottom-0 border-l-2 border-indigo-500 opacity-60'
|
||||
line.style.left = (i * stepX) + 'px'
|
||||
gridContainer.appendChild(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (gridH > 0) {
|
||||
const stepY = canvas.offsetHeight / gridH
|
||||
for (let i = 0; i <= gridH; i++) {
|
||||
const line = document.createElement('div')
|
||||
line.className = 'absolute left-0 right-0 border-t-2 border-indigo-500 opacity-60'
|
||||
line.style.top = (i * stepY) + 'px'
|
||||
gridContainer.appendChild(line)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.insertBefore(gridContainer, canvas.firstChild)
|
||||
}
|
||||
|
||||
function removeGrid() {
|
||||
const existingGrid = document.getElementById('pdfGrid')
|
||||
if (existingGrid) existingGrid.remove()
|
||||
}
|
||||
|
||||
if (gotoPageBtn && gotoPageInput) {
|
||||
gotoPageBtn.addEventListener('click', () => {
|
||||
const pageNum = parseInt(gotoPageInput.value)
|
||||
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) {
|
||||
currentPageIndex = pageNum - 1
|
||||
renderCanvas()
|
||||
updatePageNavigation()
|
||||
} else {
|
||||
alert(`Please enter a valid page number between 1 and ${pages.length}`)
|
||||
}
|
||||
})
|
||||
|
||||
gotoPageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
gotoPageBtn.click()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Tool item interactions
|
||||
const toolItems = document.querySelectorAll('.tool-item')
|
||||
@@ -109,10 +199,13 @@ toolItems.forEach(item => {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
const type = (item as HTMLElement).dataset.type || 'text'
|
||||
e.dataTransfer.setData('text/plain', type)
|
||||
if (gridEnabled) renderGrid()
|
||||
}
|
||||
})
|
||||
|
||||
// Click to select tool for placement
|
||||
item.addEventListener('dragend', () => {
|
||||
if (!gridAlwaysVisible && gridEnabled) removeGrid()
|
||||
})
|
||||
item.addEventListener('click', () => {
|
||||
const type = (item as HTMLElement).dataset.type || 'text'
|
||||
|
||||
@@ -191,8 +284,9 @@ canvas.addEventListener('dragover', (e) => {
|
||||
|
||||
canvas.addEventListener('drop', (e) => {
|
||||
e.preventDefault()
|
||||
if (!gridAlwaysVisible) removeGrid()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left - 75 // Center the field on drop point
|
||||
const x = e.clientX - rect.left - 75
|
||||
const y = e.clientY - rect.top - 15
|
||||
const type = e.dataTransfer?.getData('text/plain') || 'text'
|
||||
createField(type as any, x, y)
|
||||
@@ -246,7 +340,9 @@ function createField(type: FormField['type'], x: number, y: number): void {
|
||||
visibilityAction: type === 'button' ? 'toggle' : undefined,
|
||||
dateFormat: type === 'date' ? 'mm/dd/yyyy' : undefined,
|
||||
pageIndex: currentPageIndex,
|
||||
multiline: type === 'text' ? false : undefined
|
||||
multiline: type === 'text' ? false : undefined,
|
||||
borderColor: '#000000',
|
||||
hideBorder: false
|
||||
}
|
||||
|
||||
fields.push(field)
|
||||
@@ -263,6 +359,7 @@ function renderField(field: FormField): void {
|
||||
fieldWrapper.style.top = field.y + 'px'
|
||||
fieldWrapper.style.width = field.width + 'px'
|
||||
fieldWrapper.style.overflow = 'visible'
|
||||
fieldWrapper.style.zIndex = '10' // Ensure fields are above grid and PDF
|
||||
|
||||
// Create label - hidden by default, shown on group hover or selection
|
||||
const label = document.createElement('div')
|
||||
@@ -398,6 +495,7 @@ function renderField(field: FormField): void {
|
||||
offsetX = e.clientX - rect.left - field.x
|
||||
offsetY = e.clientY - rect.top - field.y
|
||||
selectField(field)
|
||||
if (gridEnabled) renderGrid()
|
||||
e.preventDefault()
|
||||
})
|
||||
|
||||
@@ -559,9 +657,9 @@ document.addEventListener('mouseup', () => {
|
||||
draggedElement = null
|
||||
resizing = false
|
||||
resizeField = null
|
||||
if (!gridAlwaysVisible) removeGrid()
|
||||
})
|
||||
|
||||
// Touch move for dragging and resizing
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
const touch = e.touches[0]
|
||||
if (resizing && resizeField) {
|
||||
@@ -866,6 +964,14 @@ function showProperties(field: FormField): void {
|
||||
<input type="checkbox" id="propReadOnly" ${field.readOnly ? 'checked' : ''} class="mr-2">
|
||||
<label for="propReadOnly" class="text-xs font-semibold text-gray-300">Read Only</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
|
||||
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}" class="w-full border border-gray-500 rounded px-2 py-1 h-10">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
|
||||
<label for="propHideBorder" class="text-xs font-semibold text-gray-300">Hide Border</label>
|
||||
</div>
|
||||
<button id="deleteBtn" class="w-full bg-red-600 text-white py-2 rounded hover:bg-red-700 transition text-sm font-semibold">
|
||||
Delete Field
|
||||
</button>
|
||||
@@ -963,6 +1069,17 @@ function showProperties(field: FormField): void {
|
||||
field.readOnly = (e.target as HTMLInputElement).checked
|
||||
})
|
||||
|
||||
const propBorderColor = document.getElementById('propBorderColor') as HTMLInputElement
|
||||
const propHideBorder = document.getElementById('propHideBorder') as HTMLInputElement
|
||||
|
||||
propBorderColor.addEventListener('input', (e) => {
|
||||
field.borderColor = (e.target as HTMLInputElement).value
|
||||
})
|
||||
|
||||
propHideBorder.addEventListener('change', (e) => {
|
||||
field.hideBorder = (e.target as HTMLInputElement).checked
|
||||
})
|
||||
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
deleteField(field)
|
||||
})
|
||||
@@ -1424,14 +1541,15 @@ downloadBtn.addEventListener('click', async () => {
|
||||
if (field.type === 'text') {
|
||||
const textField = form.createTextField(field.name)
|
||||
const rgbColor = hexToRgb(field.textColor)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
|
||||
textField.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b),
|
||||
})
|
||||
@@ -1474,13 +1592,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'checkbox') {
|
||||
const checkBox = form.createCheckBox(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
checkBox.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
})
|
||||
if (field.checked) checkBox.check()
|
||||
@@ -1512,13 +1631,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
})
|
||||
if (field.checked) radioGroup.select(field.exportValue || 'Yes')
|
||||
@@ -1532,13 +1652,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'dropdown') {
|
||||
const dropdown = form.createDropdown(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
dropdown.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1), // Light blue not supported in standard PDF appearance easily without streams
|
||||
})
|
||||
if (field.options) dropdown.setOptions(field.options)
|
||||
@@ -1561,13 +1682,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'optionlist') {
|
||||
const optionList = form.createOptionList(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
optionList.addToPage(pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(1, 1, 1),
|
||||
})
|
||||
if (field.options) optionList.setOptions(field.options)
|
||||
@@ -1590,13 +1712,14 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
} else if (field.type === 'button') {
|
||||
const button = form.createButton(field.name)
|
||||
const borderRgb = hexToRgb(field.borderColor || '#000000')
|
||||
button.addToPage(field.label || 'Button', pdfPage, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: width,
|
||||
height: height,
|
||||
borderWidth: 1,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: field.hideBorder ? 0 : 1,
|
||||
borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
|
||||
backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray
|
||||
})
|
||||
|
||||
@@ -1839,7 +1962,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||
const backToToolsBtns = document.querySelectorAll('[id^="back-to-tools"]') as NodeListOf<HTMLButtonElement>
|
||||
backToToolsBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
window.location.href = '/'
|
||||
window.location.href = import.meta.env.BASE_URL
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { merge, setupMergeTool } from './merge.js';
|
||||
import { setupSplitTool, split } from './split.js';
|
||||
|
||||
|
||||
import { encrypt } from './encrypt.js';
|
||||
import { decrypt } from './decrypt.js';
|
||||
import { organize } from './organize.js';
|
||||
@@ -8,7 +8,7 @@ import { addPageNumbers } from './add-page-numbers.js';
|
||||
import { pdfToJpg } from './pdf-to-jpg.js';
|
||||
import { jpgToPdf } from './jpg-to-pdf.js';
|
||||
import { scanToPdf } from './scan-to-pdf.js';
|
||||
import { compress } from './compress.js';
|
||||
|
||||
import { pdfToGreyscale } from './pdf-to-greyscale.js';
|
||||
import { pdfToZip } from './pdf-to-zip.js';
|
||||
import { editMetadata } from './edit-metadata.js';
|
||||
@@ -70,8 +70,7 @@ import { removeRestrictions } from './remove-restrictions.js';
|
||||
import { repairPdf } from './repair-pdf.js';
|
||||
|
||||
export const toolLogic = {
|
||||
merge: { process: merge, setup: setupMergeTool },
|
||||
split: { process: split, setup: setupSplitTool },
|
||||
|
||||
encrypt,
|
||||
decrypt,
|
||||
'remove-restrictions': removeRestrictions,
|
||||
@@ -82,7 +81,7 @@ export const toolLogic = {
|
||||
'pdf-to-jpg': pdfToJpg,
|
||||
'jpg-to-pdf': jpgToPdf,
|
||||
'scan-to-pdf': scanToPdf,
|
||||
compress,
|
||||
|
||||
'pdf-to-greyscale': pdfToGreyscale,
|
||||
'pdf-to-zip': pdfToZip,
|
||||
'edit-metadata': editMetadata,
|
||||
|
||||
248
src/js/logic/jpg-to-pdf-page.ts
Normal file
248
src/js/logic/jpg-to-pdf-page.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { downloadFile, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
|
||||
let files: File[] = [];
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const addMoreBtn = document.getElementById('add-more-btn');
|
||||
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
}
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
handleFiles(droppedFiles);
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('click', () => {
|
||||
fileInput?.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', () => {
|
||||
fileInput?.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearFilesBtn) {
|
||||
clearFilesBtn.addEventListener('click', () => {
|
||||
files = [];
|
||||
updateUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', convertToPdf);
|
||||
}
|
||||
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
handleFiles(input.files);
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function handleFiles(newFiles: FileList) {
|
||||
const validFiles = Array.from(newFiles).filter(file =>
|
||||
file.type === 'image/jpeg' || file.type === 'image/jpg' || file.name.toLowerCase().endsWith('.jpg') || file.name.toLowerCase().endsWith('.jpeg')
|
||||
);
|
||||
|
||||
if (validFiles.length < newFiles.length) {
|
||||
showAlert('Invalid Files', 'Some files were skipped. Only JPG/JPEG images are allowed.');
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
files = [...files, ...validFiles];
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
files = [];
|
||||
updateUI();
|
||||
};
|
||||
|
||||
function updateUI() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
const optionsDiv = document.getElementById('jpg-to-pdf-options');
|
||||
|
||||
if (!fileDisplayArea || !fileControls || !optionsDiv) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
if (files.length > 0) {
|
||||
fileControls.classList.remove('hidden');
|
||||
optionsDiv.classList.remove('hidden');
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
|
||||
sizeSpan.textContent = `(${formatBytes(file.size)})`;
|
||||
|
||||
infoContainer.append(nameSpan, sizeSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
files = files.filter((_, i) => i !== index);
|
||||
updateUI();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
});
|
||||
createIcons({ icons });
|
||||
} else {
|
||||
fileControls.classList.add('hidden');
|
||||
optionsDiv.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeImageAsJpeg(imageBytes: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([imageBytes]);
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
canvas.toBlob(
|
||||
async (jpegBlob) => {
|
||||
if (!jpegBlob) {
|
||||
return reject(new Error('Canvas toBlob conversion failed.'));
|
||||
}
|
||||
const arrayBuffer = await jpegBlob.arrayBuffer();
|
||||
resolve(new Uint8Array(arrayBuffer));
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(
|
||||
new Error(
|
||||
'The provided file could not be loaded as an image. It may be corrupted.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function convertToPdf() {
|
||||
if (files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one JPG file.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Creating PDF from JPGs...');
|
||||
|
||||
try {
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
|
||||
for (const file of files) {
|
||||
const originalBytes = await readFileAsArrayBuffer(file);
|
||||
let jpgImage;
|
||||
|
||||
try {
|
||||
jpgImage = await pdfDoc.embedJpg(originalBytes as Uint8Array);
|
||||
} catch (e) {
|
||||
showAlert(
|
||||
'Warning',
|
||||
`Direct JPG embedding failed for ${file.name}, attempting to sanitize...`
|
||||
);
|
||||
try {
|
||||
const sanitizedBytes = await sanitizeImageAsJpeg(originalBytes);
|
||||
jpgImage = await pdfDoc.embedJpg(sanitizedBytes as Uint8Array);
|
||||
} catch (fallbackError) {
|
||||
console.error(
|
||||
`Failed to process ${file.name} after sanitization:`,
|
||||
fallbackError
|
||||
);
|
||||
throw new Error(
|
||||
`Could not process "${file.name}". The file may be corrupted.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const page = pdfDoc.addPage([jpgImage.width, jpgImage.height]);
|
||||
page.drawImage(jpgImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: jpgImage.width,
|
||||
height: jpgImage.height,
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'from_jpgs.pdf'
|
||||
);
|
||||
showAlert('Success', 'PDF created successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
showAlert('Conversion Error', e.message);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import JSZip from 'jszip'
|
||||
import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers';
|
||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||
|
||||
const worker = new Worker(new URL('/workers/json-to-pdf.worker.js', import.meta.url));
|
||||
const worker = new Worker(import.meta.env.BASE_URL + 'workers/json-to-pdf.worker.js');
|
||||
|
||||
let selectedFiles: File[] = []
|
||||
|
||||
@@ -148,7 +148,7 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
|
||||
if (backToToolsBtn) {
|
||||
backToToolsBtn.addEventListener('click', () => {
|
||||
window.location.href = '/'
|
||||
window.location.href = import.meta.env.BASE_URL
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
612
src/js/logic/merge-pdf-page.ts
Normal file
612
src/js/logic/merge-pdf-page.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
|
||||
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
// @ts-ignore
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
interface MergeState {
|
||||
pdfDocs: Record<string, any>;
|
||||
pdfBytes: Record<string, ArrayBuffer>;
|
||||
activeMode: 'file' | 'page';
|
||||
sortableInstances: {
|
||||
fileList?: Sortable;
|
||||
pageThumbnails?: Sortable;
|
||||
};
|
||||
isRendering: boolean;
|
||||
cachedThumbnails: boolean | null;
|
||||
lastFileHash: string | null;
|
||||
mergeSuccess: boolean;
|
||||
}
|
||||
|
||||
const mergeState: MergeState = {
|
||||
pdfDocs: {},
|
||||
pdfBytes: {},
|
||||
activeMode: 'file',
|
||||
sortableInstances: {},
|
||||
isRendering: false,
|
||||
cachedThumbnails: null,
|
||||
lastFileHash: null,
|
||||
mergeSuccess: false,
|
||||
};
|
||||
|
||||
const mergeWorker = new Worker(import.meta.env.BASE_URL + 'workers/merge.worker.js');
|
||||
|
||||
function initializeFileListSortable() {
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (!fileList) return;
|
||||
|
||||
if (mergeState.sortableInstances.fileList) {
|
||||
mergeState.sortableInstances.fileList.destroy();
|
||||
}
|
||||
|
||||
mergeState.sortableInstances.fileList = Sortable.create(fileList, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
onStart: function (evt: any) {
|
||||
evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: function (evt: any) {
|
||||
evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initializePageThumbnailsSortable() {
|
||||
const container = document.getElementById('page-merge-preview');
|
||||
if (!container) return;
|
||||
|
||||
if (mergeState.sortableInstances.pageThumbnails) {
|
||||
mergeState.sortableInstances.pageThumbnails.destroy();
|
||||
}
|
||||
|
||||
mergeState.sortableInstances.pageThumbnails = Sortable.create(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
onStart: function (evt: any) {
|
||||
evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: function (evt: any) {
|
||||
evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function generateFileHash() {
|
||||
return (state.files as File[])
|
||||
.map((f) => `${f.name}-${f.size}-${f.lastModified}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
async function renderPageMergeThumbnails() {
|
||||
const container = document.getElementById('page-merge-preview');
|
||||
if (!container) return;
|
||||
|
||||
const currentFileHash = generateFileHash();
|
||||
const filesChanged = currentFileHash !== mergeState.lastFileHash;
|
||||
|
||||
if (!filesChanged && mergeState.cachedThumbnails !== null) {
|
||||
// Simple check to see if it's already rendered to avoid flicker.
|
||||
if (container.firstChild) {
|
||||
initializePageThumbnailsSortable();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeState.isRendering) {
|
||||
return;
|
||||
}
|
||||
|
||||
mergeState.isRendering = true;
|
||||
container.textContent = '';
|
||||
|
||||
cleanupLazyRendering();
|
||||
|
||||
let totalPages = 0;
|
||||
for (const file of state.files) {
|
||||
const doc = mergeState.pdfDocs[file.name];
|
||||
if (doc) totalPages += doc.numPages;
|
||||
}
|
||||
|
||||
try {
|
||||
let currentPageNumber = 0;
|
||||
|
||||
// Function to create wrapper element for each page
|
||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number, fileName?: string) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className =
|
||||
'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors';
|
||||
wrapper.dataset.fileName = fileName || '';
|
||||
wrapper.dataset.pageIndex = (pageNumber - 1).toString();
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
|
||||
const pageNumDiv = document.createElement('div');
|
||||
pageNumDiv.className =
|
||||
'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg';
|
||||
pageNumDiv.textContent = pageNumber.toString();
|
||||
|
||||
imgContainer.append(img, pageNumDiv);
|
||||
|
||||
const fileNamePara = document.createElement('p');
|
||||
fileNamePara.className =
|
||||
'text-xs text-gray-400 truncate w-full text-center';
|
||||
const fullTitle = fileName ? `${fileName} (page ${pageNumber})` : `Page ${pageNumber}`;
|
||||
fileNamePara.title = fullTitle;
|
||||
fileNamePara.textContent = fileName
|
||||
? `${fileName.substring(0, 10)}... (p${pageNumber})`
|
||||
: `Page ${pageNumber}`;
|
||||
|
||||
wrapper.append(imgContainer, fileNamePara);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
// Render pages from all files progressively
|
||||
for (const file of state.files) {
|
||||
const pdfjsDoc = mergeState.pdfDocs[file.name];
|
||||
if (!pdfjsDoc) continue;
|
||||
|
||||
// Create a wrapper function that includes the file name
|
||||
const createWrapperWithFileName = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||
return createWrapper(canvas, pageNumber, file.name);
|
||||
};
|
||||
|
||||
// Render pages progressively with lazy loading
|
||||
await renderPagesProgressively(
|
||||
pdfjsDoc,
|
||||
container,
|
||||
createWrapperWithFileName,
|
||||
{
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '300px',
|
||||
onProgress: (current, total) => {
|
||||
currentPageNumber++;
|
||||
showLoader(
|
||||
`Rendering page previews...`
|
||||
);
|
||||
},
|
||||
onBatchComplete: () => {
|
||||
createIcons({ icons });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
mergeState.cachedThumbnails = true;
|
||||
mergeState.lastFileHash = currentFileHash;
|
||||
|
||||
initializePageThumbnailsSortable();
|
||||
} catch (error) {
|
||||
console.error('Error rendering page thumbnails:', error);
|
||||
showAlert('Error', 'Failed to render page thumbnails');
|
||||
} finally {
|
||||
hideLoader();
|
||||
mergeState.isRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
const updateUI = async () => {
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
const mergeOptions = document.getElementById('merge-options');
|
||||
|
||||
if (state.files.length > 0) {
|
||||
if (fileControls) fileControls.classList.remove('hidden');
|
||||
if (mergeOptions) mergeOptions.classList.remove('hidden');
|
||||
await refreshMergeUI();
|
||||
} else {
|
||||
if (fileControls) fileControls.classList.add('hidden');
|
||||
if (mergeOptions) mergeOptions.classList.add('hidden');
|
||||
// Clear file list UI
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (fileList) fileList.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = async () => {
|
||||
state.files = [];
|
||||
state.pdfDoc = null;
|
||||
|
||||
mergeState.pdfDocs = {};
|
||||
mergeState.pdfBytes = {};
|
||||
mergeState.activeMode = 'file';
|
||||
mergeState.cachedThumbnails = null;
|
||||
mergeState.lastFileHash = null;
|
||||
mergeState.mergeSuccess = false;
|
||||
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (fileList) fileList.innerHTML = '';
|
||||
|
||||
const pageMergePreview = document.getElementById('page-merge-preview');
|
||||
if (pageMergePreview) pageMergePreview.innerHTML = '';
|
||||
|
||||
const fileModeBtn = document.getElementById('file-mode-btn');
|
||||
const pageModeBtn = document.getElementById('page-mode-btn');
|
||||
const filePanel = document.getElementById('file-mode-panel');
|
||||
const pagePanel = document.getElementById('page-mode-panel');
|
||||
|
||||
if (fileModeBtn && pageModeBtn && filePanel && pagePanel) {
|
||||
fileModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
fileModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
pageModeBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
pageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
|
||||
filePanel.classList.remove('hidden');
|
||||
pagePanel.classList.add('hidden');
|
||||
}
|
||||
|
||||
await updateUI();
|
||||
};
|
||||
|
||||
|
||||
export async function merge() {
|
||||
showLoader('Merging PDFs...');
|
||||
try {
|
||||
// @ts-ignore
|
||||
const jobs: MergeJob[] = [];
|
||||
// @ts-ignore
|
||||
const filesToMerge: MergeFile[] = [];
|
||||
const uniqueFileNames = new Set<string>();
|
||||
|
||||
if (mergeState.activeMode === 'file') {
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (!fileList) throw new Error('File list not found');
|
||||
|
||||
const sortedFiles = Array.from(fileList.children)
|
||||
.map((li) => {
|
||||
return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
if (!file) continue;
|
||||
const safeFileName = file.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const rangeInput = document.getElementById(`range-${safeFileName}`) as HTMLInputElement;
|
||||
|
||||
uniqueFileNames.add(file.name);
|
||||
|
||||
if (rangeInput && rangeInput.value.trim()) {
|
||||
jobs.push({
|
||||
fileName: file.name,
|
||||
rangeType: 'specific',
|
||||
rangeString: rangeInput.value.trim()
|
||||
});
|
||||
} else {
|
||||
jobs.push({
|
||||
fileName: file.name,
|
||||
rangeType: 'all'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Page Mode
|
||||
const pageContainer = document.getElementById('page-merge-preview');
|
||||
if (!pageContainer) throw new Error('Page container not found');
|
||||
const pageElements = Array.from(pageContainer.children);
|
||||
|
||||
const rawPages: { fileName: string; pageIndex: number }[] = [];
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const fileName = element.dataset.fileName;
|
||||
const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset
|
||||
|
||||
if (fileName && !isNaN(pageIndex)) {
|
||||
uniqueFileNames.add(fileName);
|
||||
rawPages.push({ fileName, pageIndex });
|
||||
}
|
||||
}
|
||||
|
||||
// Group contiguous pages
|
||||
for (let i = 0; i < rawPages.length; i++) {
|
||||
const current = rawPages[i];
|
||||
let endPage = current.pageIndex;
|
||||
|
||||
while (
|
||||
i + 1 < rawPages.length &&
|
||||
rawPages[i + 1].fileName === current.fileName &&
|
||||
rawPages[i + 1].pageIndex === endPage + 1
|
||||
) {
|
||||
endPage++;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (endPage === current.pageIndex) {
|
||||
// Single page
|
||||
jobs.push({
|
||||
fileName: current.fileName,
|
||||
rangeType: 'single',
|
||||
pageIndex: current.pageIndex
|
||||
});
|
||||
} else {
|
||||
// Range of pages
|
||||
jobs.push({
|
||||
fileName: current.fileName,
|
||||
rangeType: 'range',
|
||||
startPage: current.pageIndex + 1,
|
||||
endPage: endPage + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jobs.length === 0) {
|
||||
showAlert('Error', 'No files or pages selected to merge.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of uniqueFileNames) {
|
||||
const bytes = mergeState.pdfBytes[name];
|
||||
if (bytes) {
|
||||
filesToMerge.push({ name, data: bytes });
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const message: MergeMessage = {
|
||||
command: 'merge',
|
||||
files: filesToMerge,
|
||||
jobs: jobs
|
||||
};
|
||||
|
||||
mergeWorker.postMessage(message, filesToMerge.map(f => f.data));
|
||||
|
||||
// @ts-ignore
|
||||
mergeWorker.onmessage = (e: MessageEvent<MergeResponse>) => {
|
||||
hideLoader();
|
||||
if (e.data.status === 'success') {
|
||||
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
|
||||
downloadFile(blob, 'merged.pdf');
|
||||
mergeState.mergeSuccess = true;
|
||||
showAlert('Success', 'PDFs merged successfully!', 'success', async () => {
|
||||
await resetState();
|
||||
});
|
||||
} else {
|
||||
console.error('Worker merge error:', e.data.message);
|
||||
showAlert('Error', e.data.message || 'Failed to merge PDFs.');
|
||||
}
|
||||
};
|
||||
|
||||
mergeWorker.onerror = (e) => {
|
||||
hideLoader();
|
||||
console.error('Worker error:', e);
|
||||
showAlert('Error', 'An unexpected error occurred in the merge worker.');
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
console.error('Merge error:', e);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to merge PDFs. Please check that all files are valid and not password-protected.'
|
||||
);
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshMergeUI() {
|
||||
document.getElementById('merge-options')?.classList.remove('hidden');
|
||||
const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
|
||||
if (processBtn) processBtn.disabled = false;
|
||||
|
||||
const wasInPageMode = mergeState.activeMode === 'page';
|
||||
|
||||
showLoader('Loading PDF documents...');
|
||||
try {
|
||||
mergeState.pdfDocs = {};
|
||||
mergeState.pdfBytes = {};
|
||||
|
||||
for (const file of state.files) {
|
||||
const pdfBytes = await readFileAsArrayBuffer(file);
|
||||
mergeState.pdfBytes[file.name] = pdfBytes as ArrayBuffer;
|
||||
|
||||
const bytesForPdfJs = (pdfBytes as ArrayBuffer).slice(0);
|
||||
const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise;
|
||||
mergeState.pdfDocs[file.name] = pdfjsDoc;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PDFs:', error);
|
||||
showAlert('Error', 'Failed to load one or more PDF files');
|
||||
return;
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
|
||||
const fileModeBtn = document.getElementById('file-mode-btn');
|
||||
const pageModeBtn = document.getElementById('page-mode-btn');
|
||||
const filePanel = document.getElementById('file-mode-panel');
|
||||
const pagePanel = document.getElementById('page-mode-panel');
|
||||
const fileList = document.getElementById('file-list');
|
||||
|
||||
if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList) return;
|
||||
|
||||
fileList.textContent = ''; // Clear list safely
|
||||
(state.files as File[]).forEach((f) => {
|
||||
const doc = mergeState.pdfDocs[f.name];
|
||||
const pageCount = doc ? doc.numPages : 'N/A';
|
||||
const safeFileName = f.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className =
|
||||
'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
|
||||
li.dataset.fileName = f.name;
|
||||
|
||||
const mainDiv = document.createElement('div');
|
||||
mainDiv.className = 'flex items-center justify-between';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-white flex-1 mr-2';
|
||||
nameSpan.title = f.name;
|
||||
nameSpan.textContent = f.name;
|
||||
|
||||
const dragHandle = document.createElement('div');
|
||||
dragHandle.className =
|
||||
'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded transition-colors';
|
||||
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>`; // Safe: static content
|
||||
|
||||
mainDiv.append(nameSpan, dragHandle);
|
||||
|
||||
const rangeDiv = document.createElement('div');
|
||||
rangeDiv.className = 'mt-2';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `range-${safeFileName}`;
|
||||
label.className = 'text-xs text-gray-400';
|
||||
label.textContent = `Pages (e.g., 1-3, 5) - Total: ${pageCount}`;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = `range-${safeFileName}`;
|
||||
input.className =
|
||||
'w-full bg-gray-800 border border-gray-600 text-white rounded-md p-2 text-sm mt-1 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors';
|
||||
input.placeholder = 'Leave blank for all pages';
|
||||
|
||||
rangeDiv.append(label, input);
|
||||
li.append(mainDiv, rangeDiv);
|
||||
fileList.appendChild(li);
|
||||
});
|
||||
|
||||
initializeFileListSortable();
|
||||
|
||||
const newFileModeBtn = fileModeBtn.cloneNode(true) as HTMLElement;
|
||||
const newPageModeBtn = pageModeBtn.cloneNode(true) as HTMLElement;
|
||||
fileModeBtn.replaceWith(newFileModeBtn);
|
||||
pageModeBtn.replaceWith(newPageModeBtn);
|
||||
|
||||
newFileModeBtn.addEventListener('click', () => {
|
||||
if (mergeState.activeMode === 'file') return;
|
||||
|
||||
mergeState.activeMode = 'file';
|
||||
filePanel.classList.remove('hidden');
|
||||
pagePanel.classList.add('hidden');
|
||||
|
||||
newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newFileModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
newPageModeBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
});
|
||||
|
||||
newPageModeBtn.addEventListener('click', async () => {
|
||||
if (mergeState.activeMode === 'page') return;
|
||||
|
||||
mergeState.activeMode = 'page';
|
||||
filePanel.classList.add('hidden');
|
||||
pagePanel.classList.remove('hidden');
|
||||
|
||||
newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
|
||||
await renderPageMergeThumbnails();
|
||||
});
|
||||
|
||||
if (wasInPageMode) {
|
||||
mergeState.activeMode = 'page';
|
||||
filePanel.classList.add('hidden');
|
||||
pagePanel.classList.remove('hidden');
|
||||
|
||||
newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
|
||||
await renderPageMergeThumbnails();
|
||||
} else {
|
||||
newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
const addMoreBtn = document.getElementById('add-more-btn');
|
||||
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const mergeOptions = document.getElementById('merge-options');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files && files.length > 0) {
|
||||
state.files = [...state.files, ...Array.from(files)];
|
||||
await updateUI();
|
||||
}
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
|
||||
if (pdfFiles.length > 0) {
|
||||
state.files = [...state.files, ...pdfFiles];
|
||||
await updateUI();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearFilesBtn) {
|
||||
clearFilesBtn.addEventListener('click', async () => {
|
||||
state.files = [];
|
||||
await updateUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', async () => {
|
||||
await merge();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
@@ -1,470 +0,0 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.ts';
|
||||
import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.ts';
|
||||
import { state } from '../state.ts';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.ts';
|
||||
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
interface MergeState {
|
||||
pdfDocs: Record<string, any>;
|
||||
pdfBytes: Record<string, ArrayBuffer>;
|
||||
activeMode: 'file' | 'page';
|
||||
sortableInstances: {
|
||||
fileList?: Sortable;
|
||||
pageThumbnails?: Sortable;
|
||||
};
|
||||
isRendering: boolean;
|
||||
cachedThumbnails: boolean | null;
|
||||
lastFileHash: string | null;
|
||||
}
|
||||
|
||||
const mergeState: MergeState = {
|
||||
pdfDocs: {},
|
||||
pdfBytes: {},
|
||||
activeMode: 'file',
|
||||
sortableInstances: {},
|
||||
isRendering: false,
|
||||
cachedThumbnails: null,
|
||||
lastFileHash: null,
|
||||
};
|
||||
|
||||
const mergeWorker = new Worker('/workers/merge.worker.js');
|
||||
|
||||
function initializeFileListSortable() {
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (!fileList) return;
|
||||
|
||||
if (mergeState.sortableInstances.fileList) {
|
||||
mergeState.sortableInstances.fileList.destroy();
|
||||
}
|
||||
|
||||
mergeState.sortableInstances.fileList = Sortable.create(fileList, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
onStart: function (evt: any) {
|
||||
evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: function (evt: any) {
|
||||
evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initializePageThumbnailsSortable() {
|
||||
const container = document.getElementById('page-merge-preview');
|
||||
if (!container) return;
|
||||
|
||||
if (mergeState.sortableInstances.pageThumbnails) {
|
||||
mergeState.sortableInstances.pageThumbnails.destroy();
|
||||
}
|
||||
|
||||
mergeState.sortableInstances.pageThumbnails = Sortable.create(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
onStart: function (evt: any) {
|
||||
evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: function (evt: any) {
|
||||
evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function generateFileHash() {
|
||||
return (state.files as File[])
|
||||
.map((f) => `${f.name}-${f.size}-${f.lastModified}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
async function renderPageMergeThumbnails() {
|
||||
const container = document.getElementById('page-merge-preview');
|
||||
if (!container) return;
|
||||
|
||||
const currentFileHash = generateFileHash();
|
||||
const filesChanged = currentFileHash !== mergeState.lastFileHash;
|
||||
|
||||
if (!filesChanged && mergeState.cachedThumbnails !== null) {
|
||||
// Simple check to see if it's already rendered to avoid flicker.
|
||||
if (container.firstChild) {
|
||||
initializePageThumbnailsSortable();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeState.isRendering) {
|
||||
return;
|
||||
}
|
||||
|
||||
mergeState.isRendering = true;
|
||||
container.textContent = '';
|
||||
|
||||
cleanupLazyRendering();
|
||||
|
||||
let totalPages = 0;
|
||||
for (const file of state.files) {
|
||||
const doc = mergeState.pdfDocs[file.name];
|
||||
if (doc) totalPages += doc.numPages;
|
||||
}
|
||||
|
||||
try {
|
||||
let currentPageNumber = 0;
|
||||
|
||||
// Function to create wrapper element for each page
|
||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number, fileName?: string) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className =
|
||||
'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors';
|
||||
wrapper.dataset.fileName = fileName || '';
|
||||
wrapper.dataset.pageIndex = (pageNumber - 1).toString();
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
|
||||
const pageNumDiv = document.createElement('div');
|
||||
pageNumDiv.className =
|
||||
'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg';
|
||||
pageNumDiv.textContent = pageNumber.toString();
|
||||
|
||||
imgContainer.append(img, pageNumDiv);
|
||||
|
||||
const fileNamePara = document.createElement('p');
|
||||
fileNamePara.className =
|
||||
'text-xs text-gray-400 truncate w-full text-center';
|
||||
const fullTitle = fileName ? `${fileName} (page ${pageNumber})` : `Page ${pageNumber}`;
|
||||
fileNamePara.title = fullTitle;
|
||||
fileNamePara.textContent = fileName
|
||||
? `${fileName.substring(0, 10)}... (p${pageNumber})`
|
||||
: `Page ${pageNumber}`;
|
||||
|
||||
wrapper.append(imgContainer, fileNamePara);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
// Render pages from all files progressively
|
||||
for (const file of state.files) {
|
||||
const pdfjsDoc = mergeState.pdfDocs[file.name];
|
||||
if (!pdfjsDoc) continue;
|
||||
|
||||
// Create a wrapper function that includes the file name
|
||||
const createWrapperWithFileName = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||
return createWrapper(canvas, pageNumber, file.name);
|
||||
};
|
||||
|
||||
// Render pages progressively with lazy loading
|
||||
await renderPagesProgressively(
|
||||
pdfjsDoc,
|
||||
container,
|
||||
createWrapperWithFileName,
|
||||
{
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '300px',
|
||||
onProgress: (current, total) => {
|
||||
currentPageNumber++;
|
||||
showLoader(
|
||||
`Rendering page previews...`
|
||||
);
|
||||
},
|
||||
onBatchComplete: () => {
|
||||
createIcons({ icons });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
mergeState.cachedThumbnails = true;
|
||||
mergeState.lastFileHash = currentFileHash;
|
||||
|
||||
initializePageThumbnailsSortable();
|
||||
} catch (error) {
|
||||
console.error('Error rendering page thumbnails:', error);
|
||||
showAlert('Error', 'Failed to render page thumbnails');
|
||||
} finally {
|
||||
hideLoader();
|
||||
mergeState.isRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function merge() {
|
||||
showLoader('Merging PDFs...');
|
||||
try {
|
||||
const jobs: MergeJob[] = [];
|
||||
const filesToMerge: MergeFile[] = [];
|
||||
const uniqueFileNames = new Set<string>();
|
||||
|
||||
if (mergeState.activeMode === 'file') {
|
||||
const fileList = document.getElementById('file-list');
|
||||
if (!fileList) throw new Error('File list not found');
|
||||
|
||||
const sortedFiles = Array.from(fileList.children)
|
||||
.map((li) => {
|
||||
return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
if (!file) continue;
|
||||
const safeFileName = file.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const rangeInput = document.getElementById(`range-${safeFileName}`) as HTMLInputElement;
|
||||
|
||||
uniqueFileNames.add(file.name);
|
||||
|
||||
if (rangeInput && rangeInput.value.trim()) {
|
||||
jobs.push({
|
||||
fileName: file.name,
|
||||
rangeType: 'specific',
|
||||
rangeString: rangeInput.value.trim()
|
||||
});
|
||||
} else {
|
||||
jobs.push({
|
||||
fileName: file.name,
|
||||
rangeType: 'all'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Page Mode
|
||||
const pageContainer = document.getElementById('page-merge-preview');
|
||||
if (!pageContainer) throw new Error('Page container not found');
|
||||
const pageElements = Array.from(pageContainer.children);
|
||||
|
||||
const rawPages: { fileName: string; pageIndex: number }[] = [];
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const fileName = element.dataset.fileName;
|
||||
const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset
|
||||
|
||||
if (fileName && !isNaN(pageIndex)) {
|
||||
uniqueFileNames.add(fileName);
|
||||
rawPages.push({ fileName, pageIndex });
|
||||
}
|
||||
}
|
||||
|
||||
// Group contiguous pages
|
||||
for (let i = 0; i < rawPages.length; i++) {
|
||||
const current = rawPages[i];
|
||||
let endPage = current.pageIndex;
|
||||
|
||||
while (
|
||||
i + 1 < rawPages.length &&
|
||||
rawPages[i + 1].fileName === current.fileName &&
|
||||
rawPages[i + 1].pageIndex === endPage + 1
|
||||
) {
|
||||
endPage++;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (endPage === current.pageIndex) {
|
||||
// Single page
|
||||
jobs.push({
|
||||
fileName: current.fileName,
|
||||
rangeType: 'single',
|
||||
pageIndex: current.pageIndex
|
||||
});
|
||||
} else {
|
||||
// Range of pages
|
||||
jobs.push({
|
||||
fileName: current.fileName,
|
||||
rangeType: 'range',
|
||||
startPage: current.pageIndex + 1,
|
||||
endPage: endPage + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jobs.length === 0) {
|
||||
showAlert('Error', 'No files or pages selected to merge.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of uniqueFileNames) {
|
||||
const bytes = mergeState.pdfBytes[name];
|
||||
if (bytes) {
|
||||
filesToMerge.push({ name, data: bytes });
|
||||
}
|
||||
}
|
||||
|
||||
const message: MergeMessage = {
|
||||
command: 'merge',
|
||||
files: filesToMerge,
|
||||
jobs: jobs
|
||||
};
|
||||
|
||||
mergeWorker.postMessage(message, filesToMerge.map(f => f.data));
|
||||
|
||||
mergeWorker.onmessage = (e: MessageEvent<MergeResponse>) => {
|
||||
hideLoader();
|
||||
if (e.data.status === 'success') {
|
||||
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
|
||||
downloadFile(blob, 'merged.pdf');
|
||||
showAlert('Success', 'PDFs merged successfully!');
|
||||
} else {
|
||||
console.error('Worker merge error:', e.data.message);
|
||||
showAlert('Error', e.data.message || 'Failed to merge PDFs.');
|
||||
}
|
||||
};
|
||||
|
||||
mergeWorker.onerror = (e) => {
|
||||
hideLoader();
|
||||
console.error('Worker error:', e);
|
||||
showAlert('Error', 'An unexpected error occurred in the merge worker.');
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
console.error('Merge error:', e);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to merge PDFs. Please check that all files are valid and not password-protected.'
|
||||
);
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupMergeTool() {
|
||||
document.getElementById('merge-options')?.classList.remove('hidden');
|
||||
const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
|
||||
if (processBtn) processBtn.disabled = false;
|
||||
|
||||
const wasInPageMode = mergeState.activeMode === 'page';
|
||||
|
||||
showLoader('Loading PDF documents...');
|
||||
try {
|
||||
mergeState.pdfDocs = {};
|
||||
mergeState.pdfBytes = {};
|
||||
|
||||
for (const file of state.files) {
|
||||
const pdfBytes = await readFileAsArrayBuffer(file);
|
||||
mergeState.pdfBytes[file.name] = pdfBytes as ArrayBuffer;
|
||||
|
||||
const bytesForPdfJs = (pdfBytes as ArrayBuffer).slice(0);
|
||||
const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise;
|
||||
mergeState.pdfDocs[file.name] = pdfjsDoc;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PDFs:', error);
|
||||
showAlert('Error', 'Failed to load one or more PDF files');
|
||||
return;
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
|
||||
const fileModeBtn = document.getElementById('file-mode-btn');
|
||||
const pageModeBtn = document.getElementById('page-mode-btn');
|
||||
const filePanel = document.getElementById('file-mode-panel');
|
||||
const pagePanel = document.getElementById('page-mode-panel');
|
||||
const fileList = document.getElementById('file-list');
|
||||
|
||||
if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList) return;
|
||||
|
||||
fileList.textContent = ''; // Clear list safely
|
||||
(state.files as File[]).forEach((f) => {
|
||||
const doc = mergeState.pdfDocs[f.name];
|
||||
const pageCount = doc ? doc.numPages : 'N/A';
|
||||
const safeFileName = f.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className =
|
||||
'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
|
||||
li.dataset.fileName = f.name;
|
||||
|
||||
const mainDiv = document.createElement('div');
|
||||
mainDiv.className = 'flex items-center justify-between';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-white flex-1 mr-2';
|
||||
nameSpan.title = f.name;
|
||||
nameSpan.textContent = f.name;
|
||||
|
||||
const dragHandle = document.createElement('div');
|
||||
dragHandle.className =
|
||||
'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded transition-colors';
|
||||
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>`; // Safe: static content
|
||||
|
||||
mainDiv.append(nameSpan, dragHandle);
|
||||
|
||||
const rangeDiv = document.createElement('div');
|
||||
rangeDiv.className = 'mt-2';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `range-${safeFileName}`;
|
||||
label.className = 'text-xs text-gray-400';
|
||||
label.textContent = `Pages (e.g., 1-3, 5) - Total: ${pageCount}`;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = `range-${safeFileName}`;
|
||||
input.className =
|
||||
'w-full bg-gray-800 border border-gray-600 text-white rounded-md p-2 text-sm mt-1 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors';
|
||||
input.placeholder = 'Leave blank for all pages';
|
||||
|
||||
rangeDiv.append(label, input);
|
||||
li.append(mainDiv, rangeDiv);
|
||||
fileList.appendChild(li);
|
||||
});
|
||||
|
||||
initializeFileListSortable();
|
||||
|
||||
const newFileModeBtn = fileModeBtn.cloneNode(true) as HTMLElement;
|
||||
const newPageModeBtn = pageModeBtn.cloneNode(true) as HTMLElement;
|
||||
fileModeBtn.replaceWith(newFileModeBtn);
|
||||
pageModeBtn.replaceWith(newPageModeBtn);
|
||||
|
||||
newFileModeBtn.addEventListener('click', () => {
|
||||
if (mergeState.activeMode === 'file') return;
|
||||
|
||||
mergeState.activeMode = 'file';
|
||||
filePanel.classList.remove('hidden');
|
||||
pagePanel.classList.add('hidden');
|
||||
|
||||
newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newFileModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
newPageModeBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
});
|
||||
|
||||
newPageModeBtn.addEventListener('click', async () => {
|
||||
if (mergeState.activeMode === 'page') return;
|
||||
|
||||
mergeState.activeMode = 'page';
|
||||
filePanel.classList.add('hidden');
|
||||
pagePanel.classList.remove('hidden');
|
||||
|
||||
newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
|
||||
await renderPageMergeThumbnails();
|
||||
});
|
||||
|
||||
if (wasInPageMode) {
|
||||
mergeState.activeMode = 'page';
|
||||
filePanel.classList.add('hidden');
|
||||
pagePanel.classList.remove('hidden');
|
||||
|
||||
newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
|
||||
await renderPageMergeThumbnails();
|
||||
} else {
|
||||
newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,25 @@ import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/he
|
||||
import { state } from '../state.js';
|
||||
import Tesseract from 'tesseract.js';
|
||||
import { PDFDocument as PDFLibDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
import type { Word } from '../types/index.js';
|
||||
|
||||
let searchablePdfBytes: any = null;
|
||||
let searchablePdfBytes: Uint8Array | null = null;
|
||||
|
||||
function sanitizeTextForWinAnsi(text: string): string {
|
||||
// Remove invisible Unicode control characters (like Left-to-Right Mark U+200E)
|
||||
return text
|
||||
.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '')
|
||||
.replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, '');
|
||||
}
|
||||
import { getFontForLanguage } from '../utils/font-loader.js';
|
||||
|
||||
|
||||
// function sanitizeTextForWinAnsi(text: string): string {
|
||||
// // Remove invisible Unicode control characters (like Left-to-Right Mark U+200E)
|
||||
// return text
|
||||
// .replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '')
|
||||
// .replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, '');
|
||||
// }
|
||||
|
||||
function parseHOCR(hocrText: string) {
|
||||
const parser = new DOMParser();
|
||||
@@ -55,7 +60,7 @@ function parseHOCR(hocrText: string) {
|
||||
return words;
|
||||
}
|
||||
|
||||
function binarizeCanvas(ctx: any) {
|
||||
function binarizeCanvas(ctx: CanvasRenderingContext2D) {
|
||||
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
@@ -68,7 +73,7 @@ function binarizeCanvas(ctx: any) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function updateProgress(status: any, progress: any) {
|
||||
function updateProgress(status: string, progress: number) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressStatus = document.getElementById('progress-status');
|
||||
const progressLog = document.getElementById('progress-log');
|
||||
@@ -88,12 +93,13 @@ async function runOCR() {
|
||||
const selectedLangs = Array.from(
|
||||
document.querySelectorAll('.lang-checkbox:checked')
|
||||
).map((cb) => (cb as HTMLInputElement).value);
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const scale = parseFloat(document.getElementById('ocr-resolution').value);
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'checked' does not exist on type 'HTMLEle... Remove this comment to see the full error message
|
||||
const binarize = document.getElementById('ocr-binarize').checked;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const whitelist = document.getElementById('ocr-whitelist').value;
|
||||
const scale = parseFloat(
|
||||
(document.getElementById('ocr-resolution') as HTMLSelectElement).value
|
||||
);
|
||||
const binarize = (document.getElementById('ocr-binarize') as HTMLInputElement)
|
||||
.checked;
|
||||
const whitelist = (document.getElementById('ocr-whitelist') as HTMLInputElement)
|
||||
.value;
|
||||
|
||||
if (selectedLangs.length === 0) {
|
||||
showAlert(
|
||||
@@ -109,12 +115,13 @@ async function runOCR() {
|
||||
|
||||
try {
|
||||
const worker = await Tesseract.createWorker(langString, 1, {
|
||||
logger: (m: any) => updateProgress(m.status, m.progress || 0),
|
||||
logger: (m: { status: string; progress: number }) =>
|
||||
updateProgress(m.status, m.progress || 0),
|
||||
});
|
||||
|
||||
// Enable hOCR output
|
||||
await worker.setParameters({
|
||||
tessjs_create_hocr: '1',
|
||||
tessedit_pageseg_mode: Tesseract.PSM.AUTO,
|
||||
});
|
||||
|
||||
await worker.setParameters({
|
||||
@@ -125,7 +132,48 @@ async function runOCR() {
|
||||
await readFileAsArrayBuffer(state.files[0])
|
||||
).promise;
|
||||
const newPdfDoc = await PDFLibDocument.create();
|
||||
const font = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
newPdfDoc.registerFontkit(fontkit);
|
||||
|
||||
updateProgress('Loading fonts...', 0);
|
||||
|
||||
// Prioritize non-Latin languages for font selection if multiple are selected
|
||||
const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
|
||||
const indicLangs = ['hin', 'ben', 'guj', 'kan', 'mal', 'ori', 'pan', 'tam', 'tel', 'sin'];
|
||||
const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
|
||||
|
||||
const primaryLang = selectedLangs.find(l => priorityLangs.includes(l)) || selectedLangs[0] || 'eng';
|
||||
|
||||
const hasCJK = selectedLangs.some(l => cjkLangs.includes(l));
|
||||
const hasIndic = selectedLangs.some(l => indicLangs.includes(l));
|
||||
const hasLatin = selectedLangs.some(l => !priorityLangs.includes(l)) || selectedLangs.includes('eng');
|
||||
const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
|
||||
|
||||
let primaryFont;
|
||||
let latinFont;
|
||||
|
||||
try {
|
||||
let fontBytes;
|
||||
if (isIndicPlusLatin) {
|
||||
const [scriptFontBytes, latinFontBytes] = await Promise.all([
|
||||
getFontForLanguage(primaryLang),
|
||||
getFontForLanguage('eng')
|
||||
]);
|
||||
primaryFont = await newPdfDoc.embedFont(scriptFontBytes, { subset: false });
|
||||
latinFont = await newPdfDoc.embedFont(latinFontBytes, { subset: false });
|
||||
} else {
|
||||
// For CJK or single-script, use one font
|
||||
fontBytes = await getFontForLanguage(primaryLang);
|
||||
primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false });
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Font loading failed, falling back to Helvetica', e);
|
||||
primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
latinFont = primaryFont;
|
||||
showAlert('Font Warning', 'Could not load the specific font for this language. Some characters may not appear correctly.');
|
||||
}
|
||||
|
||||
let fullText = '';
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
@@ -135,10 +183,12 @@ async function runOCR() {
|
||||
);
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
|
||||
if (binarize) {
|
||||
@@ -155,8 +205,8 @@ async function runOCR() {
|
||||
const pngImageBytes = await new Promise((resolve) =>
|
||||
canvas.toBlob((blob) => {
|
||||
const reader = new FileReader();
|
||||
// @ts-expect-error TS(2769) FIXME: No overload matches this call.
|
||||
reader.onload = () => resolve(new Uint8Array(reader.result));
|
||||
reader.onload = () =>
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
reader.readAsArrayBuffer(blob);
|
||||
}, 'image/png')
|
||||
);
|
||||
@@ -172,22 +222,37 @@ async function runOCR() {
|
||||
if (data.hocr) {
|
||||
const words = parseHOCR(data.hocr);
|
||||
|
||||
words.forEach((word: any) => {
|
||||
words.forEach((word: Word) => {
|
||||
const { x0, y0, x1, y1 } = word.bbox;
|
||||
// Sanitize the text to remove characters WinAnsi cannot encode
|
||||
const text = sanitizeTextForWinAnsi(word.text);
|
||||
const text = word.text.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '');
|
||||
|
||||
// Skip words that become empty after sanitization
|
||||
if (!text.trim()) return;
|
||||
|
||||
const hasNonLatin = /[^\u0000-\u007F]/.test(text);
|
||||
const font = hasNonLatin ? primaryFont : latinFont;
|
||||
|
||||
if (!font) {
|
||||
console.warn(`Font not available for text: "${text}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bboxWidth = x1 - x0;
|
||||
const bboxHeight = y1 - y0;
|
||||
|
||||
if (bboxWidth <= 0 || bboxHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fontSize = bboxHeight * 0.9;
|
||||
let textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
while (textWidth > bboxWidth && fontSize > 1) {
|
||||
fontSize -= 0.5;
|
||||
textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
try {
|
||||
let textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
while (textWidth > bboxWidth && fontSize > 1) {
|
||||
fontSize -= 0.5;
|
||||
textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not calculate text width for "${text}":`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -200,12 +265,12 @@ async function runOCR() {
|
||||
opacity: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
// If drawing fails despite sanitization, log and skip this word
|
||||
console.warn(`Could not draw text "${text}":`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
fullText += data.text + '\n\n';
|
||||
}
|
||||
|
||||
@@ -216,23 +281,27 @@ async function runOCR() {
|
||||
document.getElementById('ocr-results').classList.remove('hidden');
|
||||
|
||||
createIcons({ icons });
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
document.getElementById('ocr-text-output').value = fullText.trim();
|
||||
(
|
||||
document.getElementById('ocr-text-output') as HTMLTextAreaElement
|
||||
).value = fullText.trim();
|
||||
|
||||
document
|
||||
.getElementById('download-searchable-pdf')
|
||||
.addEventListener('click', () => {
|
||||
downloadFile(
|
||||
new Blob([searchablePdfBytes], { type: 'application/pdf' }),
|
||||
'searchable.pdf'
|
||||
);
|
||||
if (searchablePdfBytes) {
|
||||
downloadFile(
|
||||
new Blob([searchablePdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
'searchable.pdf'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// CHANGE: The copy button logic is updated to be safer.
|
||||
document.getElementById('copy-text-btn').addEventListener('click', (e) => {
|
||||
const button = e.currentTarget as HTMLButtonElement;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme...
|
||||
const textToCopy = document.getElementById('ocr-text-output').value;
|
||||
const textToCopy = (
|
||||
document.getElementById('ocr-text-output') as HTMLTextAreaElement
|
||||
).value;
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
button.textContent = ''; // Clear the button safely
|
||||
@@ -259,8 +328,9 @@ async function runOCR() {
|
||||
document
|
||||
.getElementById('download-txt-btn')
|
||||
.addEventListener('click', () => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const textToSave = document.getElementById('ocr-text-output').value;
|
||||
const textToSave = (
|
||||
document.getElementById('ocr-text-output') as HTMLTextAreaElement
|
||||
).value;
|
||||
const blob = new Blob([textToSave], { type: 'text/plain' });
|
||||
downloadFile(blob, 'ocr-text.txt');
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ function initializeTool() {
|
||||
initializeGlobalShortcuts();
|
||||
|
||||
document.getElementById('close-tool-btn')?.addEventListener('click', () => {
|
||||
window.location.href = '/';
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
|
||||
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import JSZip from 'jszip'
|
||||
import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers';
|
||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||
|
||||
const worker = new Worker(new URL('/workers/pdf-to-json.worker.js', import.meta.url));
|
||||
const worker = new Worker(import.meta.env.BASE_URL + 'workers/pdf-to-json.worker.js');
|
||||
|
||||
let selectedFiles: File[] = []
|
||||
|
||||
@@ -144,7 +144,7 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
|
||||
if (backToToolsBtn) {
|
||||
backToToolsBtn.addEventListener('click', () => {
|
||||
window.location.href = '/'
|
||||
window.location.href = import.meta.env.BASE_URL
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = '/';
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
547
src/js/logic/split-pdf-page.ts
Normal file
547
src/js/logic/split-pdf-page.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { downloadFile, getPDFDocument, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
|
||||
import JSZip from 'jszip';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
|
||||
// @ts-ignore
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let visualSelectorRendered = false;
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const splitOptions = document.getElementById('split-options');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
|
||||
// Split Mode Elements
|
||||
const splitModeSelect = document.getElementById('split-mode') as HTMLSelectElement;
|
||||
const rangePanel = document.getElementById('range-panel');
|
||||
const visualPanel = document.getElementById('visual-select-panel');
|
||||
const evenOddPanel = document.getElementById('even-odd-panel');
|
||||
const zipOptionWrapper = document.getElementById('zip-option-wrapper');
|
||||
const allPagesPanel = document.getElementById('all-pages-panel');
|
||||
const bookmarksPanel = document.getElementById('bookmarks-panel');
|
||||
const nTimesPanel = document.getElementById('n-times-panel');
|
||||
const nTimesWarning = document.getElementById('n-times-warning');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
const updateUI = async () => {
|
||||
if (state.files.length > 0) {
|
||||
const file = state.files[0];
|
||||
if (fileDisplayArea) {
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSizeContainer = document.createElement('div');
|
||||
nameSizeContainer.className = 'flex items-center gap-2';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
|
||||
sizeSpan.textContent = `(${formatBytes(file.size)})`;
|
||||
|
||||
nameSizeContainer.append(nameSpan, sizeSpan);
|
||||
|
||||
const pagesSpan = document.createElement('span');
|
||||
pagesSpan.className = 'text-xs text-gray-500 mt-0.5';
|
||||
pagesSpan.textContent = 'Loading pages...'; // Placeholder
|
||||
|
||||
infoContainer.append(nameSizeContainer, pagesSpan);
|
||||
|
||||
// Add remove button
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
state.files = [];
|
||||
state.pdfDoc = null;
|
||||
updateUI();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
|
||||
// Load PDF Document
|
||||
try {
|
||||
if (!state.pdfDoc) {
|
||||
showLoader('Loading PDF...');
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer;
|
||||
state.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
hideLoader();
|
||||
}
|
||||
// Update page count
|
||||
pagesSpan.textContent = `${state.pdfDoc.getPageCount()} Pages`;
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
state.files = [];
|
||||
updateUI();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (splitOptions) splitOptions.classList.remove('hidden');
|
||||
|
||||
} else {
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
if (splitOptions) splitOptions.classList.add('hidden');
|
||||
state.pdfDoc = null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderVisualSelector = async () => {
|
||||
if (visualSelectorRendered) return;
|
||||
|
||||
const container = document.getElementById('page-selector-grid');
|
||||
if (!container) return;
|
||||
|
||||
visualSelectorRendered = true;
|
||||
container.textContent = '';
|
||||
|
||||
// Cleanup any previous lazy loading observers
|
||||
cleanupLazyRendering();
|
||||
|
||||
showLoader('Rendering page previews...');
|
||||
|
||||
try {
|
||||
if (!state.pdfDoc) {
|
||||
// If pdfDoc is not loaded yet (e.g. page refresh), try to load it from the first file
|
||||
if (state.files.length > 0) {
|
||||
const file = state.files[0];
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer;
|
||||
state.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
} else {
|
||||
throw new Error('No PDF document loaded');
|
||||
}
|
||||
}
|
||||
|
||||
const pdfData = await state.pdfDoc.save();
|
||||
const pdf = await getPDFDocument({ data: pdfData }).promise;
|
||||
|
||||
// Function to create wrapper element for each page
|
||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className =
|
||||
'page-thumbnail-wrapper p-1 border-2 border-transparent rounded-lg cursor-pointer hover:border-indigo-500 relative';
|
||||
wrapper.dataset.pageIndex = (pageNumber - 1).toString();
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md w-full h-auto';
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.className = 'text-center text-xs mt-1 text-gray-300';
|
||||
p.textContent = `Page ${pageNumber}`;
|
||||
|
||||
wrapper.append(img, p);
|
||||
|
||||
const handleSelection = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isSelected = wrapper.classList.contains('selected');
|
||||
|
||||
if (isSelected) {
|
||||
wrapper.classList.remove('selected', 'border-indigo-500');
|
||||
wrapper.classList.add('border-transparent');
|
||||
} else {
|
||||
wrapper.classList.add('selected', 'border-indigo-500');
|
||||
wrapper.classList.remove('border-transparent');
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('click', handleSelection);
|
||||
wrapper.addEventListener('touchend', handleSelection);
|
||||
|
||||
wrapper.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
// Render pages progressively with lazy loading
|
||||
await renderPagesProgressively(
|
||||
pdf,
|
||||
container,
|
||||
createWrapper,
|
||||
{
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '400px',
|
||||
onProgress: (current, total) => {
|
||||
showLoader(`Rendering page previews: ${current}/${total}`);
|
||||
},
|
||||
onBatchComplete: () => {
|
||||
createIcons({ icons });
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering visual selector:', error);
|
||||
showAlert('Error', 'Failed to render page previews.');
|
||||
// Reset the flag on error so the user can try again.
|
||||
visualSelectorRendered = false;
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
state.files = [];
|
||||
state.pdfDoc = null;
|
||||
|
||||
// Reset visual selection
|
||||
document.querySelectorAll('.page-thumbnail-wrapper.selected').forEach(el => {
|
||||
el.classList.remove('selected', 'border-indigo-500');
|
||||
el.classList.add('border-transparent');
|
||||
});
|
||||
visualSelectorRendered = false;
|
||||
const container = document.getElementById('page-selector-grid');
|
||||
if (container) container.innerHTML = '';
|
||||
|
||||
// Reset inputs
|
||||
const pageRangeInput = document.getElementById('page-range') as HTMLInputElement;
|
||||
if (pageRangeInput) pageRangeInput.value = '';
|
||||
|
||||
const nValueInput = document.getElementById('split-n-value') as HTMLInputElement;
|
||||
if (nValueInput) nValueInput.value = '5';
|
||||
|
||||
// Reset radio buttons to default (range)
|
||||
const rangeRadio = document.querySelector('input[name="split-mode"][value="range"]') as HTMLInputElement;
|
||||
if (rangeRadio) {
|
||||
rangeRadio.checked = true;
|
||||
rangeRadio.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
// Reset split mode select
|
||||
if (splitModeSelect) {
|
||||
splitModeSelect.value = 'range';
|
||||
splitModeSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
updateUI();
|
||||
};
|
||||
|
||||
const split = async () => {
|
||||
const splitMode = splitModeSelect.value;
|
||||
const downloadAsZip =
|
||||
(document.getElementById('download-as-zip') as HTMLInputElement)?.checked ||
|
||||
false;
|
||||
|
||||
showLoader('Splitting PDF...');
|
||||
|
||||
try {
|
||||
if (!state.pdfDoc) throw new Error('No PDF document loaded.');
|
||||
|
||||
const totalPages = state.pdfDoc.getPageCount();
|
||||
let indicesToExtract: number[] = [];
|
||||
|
||||
switch (splitMode) {
|
||||
case 'range':
|
||||
const pageRangeInput = (document.getElementById('page-range') as HTMLInputElement).value;
|
||||
if (!pageRangeInput) throw new Error('Choose a valid page range.');
|
||||
const ranges = pageRangeInput.split(',');
|
||||
for (const range of ranges) {
|
||||
const trimmedRange = range.trim();
|
||||
if (trimmedRange.includes('-')) {
|
||||
const [start, end] = trimmedRange.split('-').map(Number);
|
||||
if (
|
||||
isNaN(start) ||
|
||||
isNaN(end) ||
|
||||
start < 1 ||
|
||||
end > totalPages ||
|
||||
start > end
|
||||
)
|
||||
continue;
|
||||
for (let i = start; i <= end; i++) indicesToExtract.push(i - 1);
|
||||
} else {
|
||||
const pageNum = Number(trimmedRange);
|
||||
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
|
||||
indicesToExtract.push(pageNum - 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'even-odd':
|
||||
const choiceElement = document.querySelector(
|
||||
'input[name="even-odd-choice"]:checked'
|
||||
) as HTMLInputElement;
|
||||
if (!choiceElement) throw new Error('Please select even or odd pages.');
|
||||
const choice = choiceElement.value;
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
if (choice === 'even' && (i + 1) % 2 === 0) indicesToExtract.push(i);
|
||||
if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i);
|
||||
}
|
||||
break;
|
||||
case 'all':
|
||||
indicesToExtract = Array.from({ length: totalPages }, (_, i) => i);
|
||||
break;
|
||||
case 'visual':
|
||||
indicesToExtract = Array.from(
|
||||
document.querySelectorAll('.page-thumbnail-wrapper.selected')
|
||||
)
|
||||
.map((el) => parseInt((el as HTMLElement).dataset.pageIndex || '0'));
|
||||
break;
|
||||
case 'bookmarks':
|
||||
const { getCpdf } = await import('../utils/cpdf-helper.js');
|
||||
const cpdf = await getCpdf();
|
||||
const pdfBytes = await state.pdfDoc.save();
|
||||
const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), '');
|
||||
|
||||
cpdf.startGetBookmarkInfo(pdf);
|
||||
const bookmarkCount = cpdf.numberBookmarks();
|
||||
const bookmarkLevel = (document.getElementById('bookmark-level') as HTMLSelectElement)?.value;
|
||||
|
||||
const splitPages: number[] = [];
|
||||
for (let i = 0; i < bookmarkCount; i++) {
|
||||
const level = cpdf.getBookmarkLevel(i);
|
||||
const page = cpdf.getBookmarkPage(pdf, i);
|
||||
|
||||
if (bookmarkLevel === 'all' || level === parseInt(bookmarkLevel)) {
|
||||
if (page > 1 && !splitPages.includes(page - 1)) {
|
||||
splitPages.push(page - 1); // Convert to 0-based index
|
||||
}
|
||||
}
|
||||
}
|
||||
cpdf.endGetBookmarkInfo();
|
||||
cpdf.deletePdf(pdf);
|
||||
|
||||
if (splitPages.length === 0) {
|
||||
throw new Error('No bookmarks found at the selected level.');
|
||||
}
|
||||
|
||||
splitPages.sort((a, b) => a - b);
|
||||
const zip = new JSZip();
|
||||
|
||||
for (let i = 0; i < splitPages.length; i++) {
|
||||
const startPage = i === 0 ? 0 : splitPages[i];
|
||||
const endPage = i < splitPages.length - 1 ? splitPages[i + 1] - 1 : totalPages - 1;
|
||||
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx);
|
||||
const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices);
|
||||
copiedPages.forEach((page: any) => newPdf.addPage(page));
|
||||
const pdfBytes2 = await newPdf.save();
|
||||
zip.file(`split-${i + 1}.pdf`, pdfBytes2);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'split-by-bookmarks.zip');
|
||||
hideLoader();
|
||||
showAlert('Success', 'PDF split successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
return;
|
||||
|
||||
case 'n-times':
|
||||
const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5');
|
||||
if (nValue < 1) throw new Error('N must be at least 1.');
|
||||
|
||||
const zip2 = new JSZip();
|
||||
const numSplits = Math.ceil(totalPages / nValue);
|
||||
|
||||
for (let i = 0; i < numSplits; i++) {
|
||||
const startPage = i * nValue;
|
||||
const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
|
||||
const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx);
|
||||
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices);
|
||||
copiedPages.forEach((page: any) => newPdf.addPage(page));
|
||||
const pdfBytes3 = await newPdf.save();
|
||||
zip2.file(`split-${i + 1}.pdf`, pdfBytes3);
|
||||
}
|
||||
|
||||
const zipBlob2 = await zip2.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob2, 'split-n-times.zip');
|
||||
hideLoader();
|
||||
showAlert('Success', 'PDF split successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueIndices = [...new Set(indicesToExtract)];
|
||||
if (uniqueIndices.length === 0 && splitMode !== 'bookmarks' && splitMode !== 'n-times') {
|
||||
throw new Error('No pages were selected for splitting.');
|
||||
}
|
||||
|
||||
if (
|
||||
splitMode === 'all' ||
|
||||
(['range', 'visual'].includes(splitMode) && downloadAsZip)
|
||||
) {
|
||||
showLoader('Creating ZIP file...');
|
||||
const zip = new JSZip();
|
||||
for (const index of uniqueIndices) {
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [
|
||||
index as number,
|
||||
]);
|
||||
newPdf.addPage(copiedPage);
|
||||
const pdfBytes = await newPdf.save();
|
||||
// @ts-ignore
|
||||
zip.file(`page-${index + 1}.pdf`, pdfBytes);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'split-pages.zip');
|
||||
} else {
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
state.pdfDoc,
|
||||
uniqueIndices as number[]
|
||||
);
|
||||
copiedPages.forEach((page: any) => newPdf.addPage(page));
|
||||
const pdfBytes = await newPdf.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'split-document.pdf'
|
||||
);
|
||||
}
|
||||
|
||||
if (splitMode === 'visual') {
|
||||
visualSelectorRendered = false;
|
||||
}
|
||||
|
||||
showAlert('Success', 'PDF split successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
'Error',
|
||||
e.message || 'Failed to split PDF. Please check your selection.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (files: FileList | null) => {
|
||||
if (files && files.length > 0) {
|
||||
// Split tool only supports one file at a time
|
||||
state.files = [files[0]];
|
||||
await updateUI();
|
||||
}
|
||||
};
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files) {
|
||||
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
|
||||
if (pdfFiles.length > 0) {
|
||||
// Take only the first PDF
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(pdfFiles[0]);
|
||||
handleFileSelect(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (splitModeSelect) {
|
||||
splitModeSelect.addEventListener('change', (e) => {
|
||||
const mode = (e.target as HTMLSelectElement).value;
|
||||
|
||||
if (mode !== 'visual') {
|
||||
visualSelectorRendered = false;
|
||||
const container = document.getElementById('page-selector-grid');
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
rangePanel?.classList.add('hidden');
|
||||
visualPanel?.classList.add('hidden');
|
||||
evenOddPanel?.classList.add('hidden');
|
||||
allPagesPanel?.classList.add('hidden');
|
||||
bookmarksPanel?.classList.add('hidden');
|
||||
nTimesPanel?.classList.add('hidden');
|
||||
zipOptionWrapper?.classList.add('hidden');
|
||||
if (nTimesWarning) nTimesWarning.classList.add('hidden');
|
||||
|
||||
if (mode === 'range') {
|
||||
rangePanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
} else if (mode === 'visual') {
|
||||
visualPanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
renderVisualSelector();
|
||||
} else if (mode === 'even-odd') {
|
||||
evenOddPanel?.classList.remove('hidden');
|
||||
} else if (mode === 'all') {
|
||||
allPagesPanel?.classList.remove('hidden');
|
||||
} else if (mode === 'bookmarks') {
|
||||
bookmarksPanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
} else if (mode === 'n-times') {
|
||||
nTimesPanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
|
||||
const updateWarning = () => {
|
||||
if (!state.pdfDoc) return;
|
||||
const totalPages = state.pdfDoc.getPageCount();
|
||||
const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5');
|
||||
const remainder = totalPages % nValue;
|
||||
if (remainder !== 0 && nTimesWarning) {
|
||||
nTimesWarning.classList.remove('hidden');
|
||||
const warningText = document.getElementById('n-times-warning-text');
|
||||
if (warningText) {
|
||||
warningText.textContent = `The PDF has ${totalPages} pages, which is not evenly divisible by ${nValue}. The last PDF will contain ${remainder} page(s).`;
|
||||
}
|
||||
} else if (nTimesWarning) {
|
||||
nTimesWarning.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
updateWarning();
|
||||
document.getElementById('split-n-value')?.addEventListener('input', updateWarning);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', split);
|
||||
}
|
||||
});
|
||||
@@ -1,361 +0,0 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { downloadFile, getPDFDocument } from '../utils/helpers.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
import { state } from '../state.js';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
|
||||
let visualSelectorRendered = false;
|
||||
|
||||
async function renderVisualSelector() {
|
||||
if (visualSelectorRendered) return;
|
||||
|
||||
const container = document.getElementById('page-selector-grid');
|
||||
if (!container) return;
|
||||
|
||||
visualSelectorRendered = true;
|
||||
|
||||
container.textContent = '';
|
||||
|
||||
// Cleanup any previous lazy loading observers
|
||||
cleanupLazyRendering();
|
||||
|
||||
showLoader('Rendering page previews...');
|
||||
|
||||
try {
|
||||
const pdfData = await state.pdfDoc.save();
|
||||
const pdf = await getPDFDocument({ data: pdfData }).promise;
|
||||
|
||||
// Function to create wrapper element for each page
|
||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className =
|
||||
'page-thumbnail-wrapper p-1 border-2 border-transparent rounded-lg cursor-pointer hover:border-indigo-500';
|
||||
// @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'.
|
||||
wrapper.dataset.pageIndex = pageNumber - 1;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md w-full h-auto';
|
||||
const p = document.createElement('p');
|
||||
p.className = 'text-center text-xs mt-1 text-gray-300';
|
||||
p.textContent = `Page ${pageNumber}`;
|
||||
wrapper.append(img, p);
|
||||
|
||||
const handleSelection = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isSelected = wrapper.classList.contains('selected');
|
||||
|
||||
if (isSelected) {
|
||||
wrapper.classList.remove('selected', 'border-indigo-500');
|
||||
wrapper.classList.add('border-transparent');
|
||||
} else {
|
||||
wrapper.classList.add('selected', 'border-indigo-500');
|
||||
wrapper.classList.remove('border-transparent');
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('click', handleSelection);
|
||||
wrapper.addEventListener('touchend', handleSelection);
|
||||
|
||||
wrapper.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
// Render pages progressively with lazy loading
|
||||
await renderPagesProgressively(
|
||||
pdf,
|
||||
container,
|
||||
createWrapper,
|
||||
{
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '400px',
|
||||
onProgress: (current, total) => {
|
||||
showLoader(`Rendering page previews: ${current}/${total}`);
|
||||
},
|
||||
onBatchComplete: () => {
|
||||
createIcons({ icons });
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering visual selector:', error);
|
||||
showAlert('Error', 'Failed to render page previews.');
|
||||
// Reset the flag on error so the user can try again.
|
||||
visualSelectorRendered = false;
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
export function setupSplitTool() {
|
||||
const splitModeSelect = document.getElementById('split-mode');
|
||||
const rangePanel = document.getElementById('range-panel');
|
||||
const visualPanel = document.getElementById('visual-select-panel');
|
||||
const evenOddPanel = document.getElementById('even-odd-panel');
|
||||
const zipOptionWrapper = document.getElementById('zip-option-wrapper');
|
||||
const allPagesPanel = document.getElementById('all-pages-panel');
|
||||
const bookmarksPanel = document.getElementById('bookmarks-panel');
|
||||
const nTimesPanel = document.getElementById('n-times-panel');
|
||||
const nTimesWarning = document.getElementById('n-times-warning');
|
||||
|
||||
if (!splitModeSelect) return;
|
||||
|
||||
splitModeSelect.addEventListener('change', (e) => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'EventTarg... Remove this comment to see the full error message
|
||||
const mode = e.target.value;
|
||||
|
||||
if (mode !== 'visual') {
|
||||
visualSelectorRendered = false;
|
||||
const container = document.getElementById('page-selector-grid');
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
rangePanel.classList.add('hidden');
|
||||
visualPanel.classList.add('hidden');
|
||||
evenOddPanel.classList.add('hidden');
|
||||
allPagesPanel.classList.add('hidden');
|
||||
bookmarksPanel.classList.add('hidden');
|
||||
nTimesPanel.classList.add('hidden');
|
||||
zipOptionWrapper.classList.add('hidden');
|
||||
if (nTimesWarning) nTimesWarning.classList.add('hidden');
|
||||
|
||||
if (mode === 'range') {
|
||||
rangePanel.classList.remove('hidden');
|
||||
zipOptionWrapper.classList.remove('hidden');
|
||||
} else if (mode === 'visual') {
|
||||
visualPanel.classList.remove('hidden');
|
||||
zipOptionWrapper.classList.remove('hidden');
|
||||
renderVisualSelector();
|
||||
} else if (mode === 'even-odd') {
|
||||
evenOddPanel.classList.remove('hidden');
|
||||
} else if (mode === 'all') {
|
||||
allPagesPanel.classList.remove('hidden');
|
||||
} else if (mode === 'bookmarks') {
|
||||
bookmarksPanel.classList.remove('hidden');
|
||||
zipOptionWrapper.classList.remove('hidden');
|
||||
} else if (mode === 'n-times') {
|
||||
nTimesPanel.classList.remove('hidden');
|
||||
zipOptionWrapper.classList.remove('hidden');
|
||||
|
||||
const updateWarning = () => {
|
||||
if (!state.pdfDoc) return;
|
||||
const totalPages = state.pdfDoc.getPageCount();
|
||||
const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5');
|
||||
const remainder = totalPages % nValue;
|
||||
if (remainder !== 0 && nTimesWarning) {
|
||||
nTimesWarning.classList.remove('hidden');
|
||||
const warningText = document.getElementById('n-times-warning-text');
|
||||
if (warningText) {
|
||||
warningText.textContent = `The PDF has ${totalPages} pages, which is not evenly divisible by ${nValue}. The last PDF will contain ${remainder} page(s).`;
|
||||
}
|
||||
} else if (nTimesWarning) {
|
||||
nTimesWarning.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
const nValueInput = document.getElementById('split-n-value') as HTMLInputElement;
|
||||
if (nValueInput) {
|
||||
nValueInput.addEventListener('input', updateWarning);
|
||||
updateWarning();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function split() {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const splitMode = document.getElementById('split-mode').value;
|
||||
const downloadAsZip =
|
||||
(document.getElementById('download-as-zip') as HTMLInputElement)?.checked ||
|
||||
false;
|
||||
|
||||
showLoader('Splitting PDF...');
|
||||
|
||||
try {
|
||||
const totalPages = state.pdfDoc.getPageCount();
|
||||
let indicesToExtract: any = [];
|
||||
|
||||
switch (splitMode) {
|
||||
case 'range':
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const pageRangeInput = document.getElementById('page-range').value;
|
||||
if (!pageRangeInput) throw new Error('Please enter a page range.');
|
||||
const ranges = pageRangeInput.split(',');
|
||||
for (const range of ranges) {
|
||||
const trimmedRange = range.trim();
|
||||
if (trimmedRange.includes('-')) {
|
||||
const [start, end] = trimmedRange.split('-').map(Number);
|
||||
if (
|
||||
isNaN(start) ||
|
||||
isNaN(end) ||
|
||||
start < 1 ||
|
||||
end > totalPages ||
|
||||
start > end
|
||||
)
|
||||
continue;
|
||||
for (let i = start; i <= end; i++) indicesToExtract.push(i - 1);
|
||||
} else {
|
||||
const pageNum = Number(trimmedRange);
|
||||
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
|
||||
indicesToExtract.push(pageNum - 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'even-odd':
|
||||
const choiceElement = document.querySelector(
|
||||
'input[name="even-odd-choice"]:checked'
|
||||
);
|
||||
if (!choiceElement) throw new Error('Please select even or odd pages.');
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'Element'.
|
||||
const choice = choiceElement.value;
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
if (choice === 'even' && (i + 1) % 2 === 0) indicesToExtract.push(i);
|
||||
if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i);
|
||||
}
|
||||
break;
|
||||
case 'all':
|
||||
indicesToExtract = Array.from({ length: totalPages }, (_, i) => i);
|
||||
break;
|
||||
case 'visual':
|
||||
indicesToExtract = Array.from(
|
||||
document.querySelectorAll('.page-thumbnail-wrapper.selected')
|
||||
)
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'dataset' does not exist on type 'Element... Remove this comment to see the full error message
|
||||
.map((el) => parseInt(el.dataset.pageIndex));
|
||||
break;
|
||||
case 'bookmarks':
|
||||
const { getCpdf } = await import('../utils/cpdf-helper.js');
|
||||
const cpdf = await getCpdf();
|
||||
const pdfBytes = await state.pdfDoc.save();
|
||||
const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), '');
|
||||
|
||||
cpdf.startGetBookmarkInfo(pdf);
|
||||
const bookmarkCount = cpdf.numberBookmarks();
|
||||
const bookmarkLevel = (document.getElementById('bookmark-level') as HTMLSelectElement)?.value;
|
||||
|
||||
const splitPages: number[] = [];
|
||||
for (let i = 0; i < bookmarkCount; i++) {
|
||||
const level = cpdf.getBookmarkLevel(i);
|
||||
const page = cpdf.getBookmarkPage(pdf, i);
|
||||
|
||||
if (bookmarkLevel === 'all' || level === parseInt(bookmarkLevel)) {
|
||||
if (page > 1 && !splitPages.includes(page - 1)) {
|
||||
splitPages.push(page - 1); // Convert to 0-based index
|
||||
}
|
||||
}
|
||||
}
|
||||
cpdf.endGetBookmarkInfo();
|
||||
cpdf.deletePdf(pdf);
|
||||
|
||||
if (splitPages.length === 0) {
|
||||
throw new Error('No bookmarks found at the selected level.');
|
||||
}
|
||||
|
||||
splitPages.sort((a, b) => a - b);
|
||||
const zip = new JSZip();
|
||||
|
||||
for (let i = 0; i < splitPages.length; i++) {
|
||||
const startPage = i === 0 ? 0 : splitPages[i];
|
||||
const endPage = i < splitPages.length - 1 ? splitPages[i + 1] - 1 : totalPages - 1;
|
||||
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx);
|
||||
const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices);
|
||||
copiedPages.forEach((page: any) => newPdf.addPage(page));
|
||||
const pdfBytes2 = await newPdf.save();
|
||||
zip.file(`split-${i + 1}.pdf`, pdfBytes2);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'split-by-bookmarks.zip');
|
||||
hideLoader();
|
||||
return;
|
||||
|
||||
case 'n-times':
|
||||
const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5');
|
||||
if (nValue < 1) throw new Error('N must be at least 1.');
|
||||
|
||||
const zip2 = new JSZip();
|
||||
const numSplits = Math.ceil(totalPages / nValue);
|
||||
|
||||
for (let i = 0; i < numSplits; i++) {
|
||||
const startPage = i * nValue;
|
||||
const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
|
||||
const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx);
|
||||
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices);
|
||||
copiedPages.forEach((page: any) => newPdf.addPage(page));
|
||||
const pdfBytes3 = await newPdf.save();
|
||||
zip2.file(`split-${i + 1}.pdf`, pdfBytes3);
|
||||
}
|
||||
|
||||
const zipBlob2 = await zip2.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob2, 'split-n-times.zip');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueIndices = [...new Set(indicesToExtract)];
|
||||
if (uniqueIndices.length === 0 && splitMode !== 'bookmarks' && splitMode !== 'n-times') {
|
||||
throw new Error('No pages were selected for splitting.');
|
||||
}
|
||||
|
||||
if (
|
||||
splitMode === 'all' ||
|
||||
(['range', 'visual'].includes(splitMode) && downloadAsZip)
|
||||
) {
|
||||
showLoader('Creating ZIP file...');
|
||||
const zip = new JSZip();
|
||||
for (const index of uniqueIndices) {
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [
|
||||
index as number,
|
||||
]);
|
||||
newPdf.addPage(copiedPage);
|
||||
const pdfBytes = await newPdf.save();
|
||||
// @ts-expect-error TS(2365) FIXME: Operator '+' cannot be applied to types 'unknown' ... Remove this comment to see the full error message
|
||||
zip.file(`page-${index + 1}.pdf`, pdfBytes);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'split-pages.zip');
|
||||
} else {
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
state.pdfDoc,
|
||||
uniqueIndices as number[]
|
||||
);
|
||||
copiedPages.forEach((page: any) => newPdf.addPage(page));
|
||||
const pdfBytes = await newPdf.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'split-document.pdf'
|
||||
);
|
||||
}
|
||||
|
||||
if (splitMode === 'visual') {
|
||||
visualSelectorRendered = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
'Error',
|
||||
e.message || 'Failed to split PDF. Please check your selection.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { downloadFile, formatBytes } from "../utils/helpers";
|
||||
import { initializeGlobalShortcuts } from "../utils/shortcuts-init.js";
|
||||
|
||||
|
||||
const worker = new Worker('/workers/table-of-contents.worker.js');
|
||||
const worker = new Worker(import.meta.env.BASE_URL + 'workers/table-of-contents.worker.js');
|
||||
|
||||
let pdfFile: File | null = null;
|
||||
|
||||
@@ -199,7 +199,7 @@ worker.onerror = (error) => {
|
||||
|
||||
if (backToToolsBtn) {
|
||||
backToToolsBtn.addEventListener('click', () => {
|
||||
window.location.href = '/';
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, hexToRgb } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import JSZip from 'jszip';
|
||||
import { getFontForLanguage, getLanguageForChar } from '../utils/font-loader.js';
|
||||
import { languageToFontFamily } from '../config/font-mappings.js';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
|
||||
import {
|
||||
PDFDocument as PDFLibDocument,
|
||||
@@ -10,69 +13,46 @@ import {
|
||||
PageSizes,
|
||||
} from 'pdf-lib';
|
||||
|
||||
function sanitizeTextForPdf(text: string): string {
|
||||
return text
|
||||
.split('')
|
||||
.map((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
|
||||
if (code === 0x20 || code === 0x09 || code === 0x0A) {
|
||||
return char;
|
||||
}
|
||||
|
||||
if ((code >= 0x00 && code <= 0x1F) || (code >= 0x7F && code <= 0x9F)) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
if (code < 0x20 || (code > 0x7E && code < 0xA0)) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
const replacements: { [key: number]: string } = {
|
||||
0x2018: "'",
|
||||
0x2019: "'",
|
||||
0x201C: '"',
|
||||
0x201D: '"',
|
||||
0x2013: '-',
|
||||
0x2014: '--',
|
||||
0x2026: '...',
|
||||
0x00A0: ' ',
|
||||
};
|
||||
|
||||
if (replacements[code]) {
|
||||
return replacements[code];
|
||||
}
|
||||
|
||||
try {
|
||||
if (code <= 0xFF) {
|
||||
return char;
|
||||
}
|
||||
return '?';
|
||||
} catch {
|
||||
return '?';
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createPdfFromText(
|
||||
text: string,
|
||||
fontFamilyKey: string,
|
||||
selectedLanguages: string[],
|
||||
fontSize: number,
|
||||
pageSizeKey: string,
|
||||
colorHex: string
|
||||
colorHex: string,
|
||||
orientation: string,
|
||||
customWidth?: number,
|
||||
customHeight?: number
|
||||
): Promise<Uint8Array> {
|
||||
const sanitizedText = sanitizeTextForPdf(text);
|
||||
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts[fontFamilyKey]);
|
||||
const pageSize = PageSizes[pageSizeKey];
|
||||
pdfDoc.registerFontkit(fontkit);
|
||||
|
||||
console.log(`User selected languages: ${selectedLanguages.join(', ')}`);
|
||||
|
||||
const fontMap = new Map<string, any>();
|
||||
const fallbackFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
if (!selectedLanguages.includes('eng')) {
|
||||
selectedLanguages.push('eng');
|
||||
}
|
||||
|
||||
for (const lang of selectedLanguages) {
|
||||
try {
|
||||
const fontBytes = await getFontForLanguage(lang);
|
||||
const font = await pdfDoc.embedFont(fontBytes, { subset: false });
|
||||
fontMap.set(lang, font);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load font for ${lang}, using fallback`, e);
|
||||
fontMap.set(lang, fallbackFont);
|
||||
}
|
||||
}
|
||||
|
||||
let pageSize = pageSizeKey === 'Custom'
|
||||
? [customWidth || 595, customHeight || 842] as [number, number]
|
||||
: PageSizes[pageSizeKey];
|
||||
|
||||
if (orientation === 'landscape') {
|
||||
pageSize = [pageSize[1], pageSize[0]] as [number, number];
|
||||
}
|
||||
const margin = 72;
|
||||
const textColor = hexToRgb(colorHex);
|
||||
|
||||
@@ -82,43 +62,130 @@ async function createPdfFromText(
|
||||
const lineHeight = fontSize * 1.3;
|
||||
let y = height - margin;
|
||||
|
||||
const paragraphs = sanitizedText.split('\n');
|
||||
const paragraphs = text.split('\n');
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.trim() === '') {
|
||||
y -= lineHeight;
|
||||
if (y < margin) {
|
||||
page = pdfDoc.addPage(pageSize);
|
||||
y = page.getHeight() - margin;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const words = paragraph.split(' ');
|
||||
let currentLine = '';
|
||||
let currentLineWords: { text: string; font: any }[] = [];
|
||||
let currentLineWidth = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine =
|
||||
currentLine.length > 0 ? `${currentLine} ${word}` : word;
|
||||
if (font.widthOfTextAtSize(testLine, fontSize) <= textWidth) {
|
||||
currentLine = testLine;
|
||||
let wordLang = 'eng';
|
||||
|
||||
for (const char of word) {
|
||||
const charLang = getLanguageForChar(char);
|
||||
|
||||
if (charLang === 'chi_sim') {
|
||||
if (selectedLanguages.includes('jpn')) wordLang = 'jpn';
|
||||
else if (selectedLanguages.includes('kor')) wordLang = 'kor';
|
||||
else if (selectedLanguages.includes('chi_tra')) wordLang = 'chi_tra';
|
||||
else if (selectedLanguages.includes('chi_sim')) wordLang = 'chi_sim';
|
||||
} else if (selectedLanguages.includes(charLang)) {
|
||||
wordLang = charLang;
|
||||
}
|
||||
|
||||
if (wordLang !== 'eng') break;
|
||||
}
|
||||
|
||||
const font = fontMap.get(wordLang) || fontMap.get('eng') || fallbackFont;
|
||||
|
||||
let wordWidth = 0;
|
||||
try {
|
||||
wordWidth = font.widthOfTextAtSize(word, fontSize);
|
||||
} catch (e) {
|
||||
console.warn(`Width calculation failed for "${word}"`, e);
|
||||
wordWidth = word.length * fontSize * 0.5;
|
||||
}
|
||||
|
||||
let spaceWidth = 0;
|
||||
if (currentLineWords.length > 0) {
|
||||
try {
|
||||
spaceWidth = font.widthOfTextAtSize(' ', fontSize);
|
||||
} catch {
|
||||
spaceWidth = fontSize * 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLineWidth + spaceWidth + wordWidth <= textWidth) {
|
||||
currentLineWords.push({ text: word, font });
|
||||
currentLineWidth += spaceWidth + wordWidth;
|
||||
} else {
|
||||
// Draw current line
|
||||
if (y < margin + lineHeight) {
|
||||
page = pdfDoc.addPage(pageSize);
|
||||
y = page.getHeight() - margin;
|
||||
}
|
||||
page.drawText(currentLine, {
|
||||
x: margin,
|
||||
y,
|
||||
font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
let currentX = margin;
|
||||
for (let i = 0; i < currentLineWords.length; i++) {
|
||||
const w = currentLineWords[i];
|
||||
try {
|
||||
page.drawText(w.text, {
|
||||
x: currentX,
|
||||
y,
|
||||
font: w.font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
const wWidth = w.font.widthOfTextAtSize(w.text, fontSize);
|
||||
currentX += wWidth;
|
||||
|
||||
if (i < currentLineWords.length - 1) {
|
||||
const sWidth = w.font.widthOfTextAtSize(' ', fontSize);
|
||||
currentX += sWidth;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to draw word: "${w.text}"`, e);
|
||||
}
|
||||
}
|
||||
|
||||
y -= lineHeight;
|
||||
currentLine = word;
|
||||
|
||||
currentLineWords = [{ text: word, font }];
|
||||
currentLineWidth = wordWidth;
|
||||
}
|
||||
}
|
||||
if (currentLine.length > 0) {
|
||||
|
||||
if (currentLineWords.length > 0) {
|
||||
if (y < margin + lineHeight) {
|
||||
page = pdfDoc.addPage(pageSize);
|
||||
y = page.getHeight() - margin;
|
||||
}
|
||||
page.drawText(currentLine, {
|
||||
x: margin,
|
||||
y,
|
||||
font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
let currentX = margin;
|
||||
for (let i = 0; i < currentLineWords.length; i++) {
|
||||
const w = currentLineWords[i];
|
||||
try {
|
||||
page.drawText(w.text, {
|
||||
x: currentX,
|
||||
y,
|
||||
font: w.font,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
|
||||
const wWidth = w.font.widthOfTextAtSize(w.text, fontSize);
|
||||
currentX += wWidth;
|
||||
|
||||
if (i < currentLineWords.length - 1) {
|
||||
const sWidth = w.font.widthOfTextAtSize(' ', fontSize);
|
||||
currentX += sWidth;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to draw word: "${w.text}"`, e);
|
||||
}
|
||||
}
|
||||
|
||||
y -= lineHeight;
|
||||
}
|
||||
}
|
||||
@@ -134,6 +201,99 @@ export async function setupTxtToPdfTool() {
|
||||
|
||||
if (!uploadBtn || !textBtn || !uploadPanel || !textPanel) return;
|
||||
|
||||
const langContainer = document.getElementById('language-list-container');
|
||||
const dropdownBtn = document.getElementById('lang-dropdown-btn');
|
||||
const dropdownContent = document.getElementById('lang-dropdown-content');
|
||||
const dropdownText = document.getElementById('lang-dropdown-text');
|
||||
const searchInput = document.getElementById('lang-search');
|
||||
|
||||
if (langContainer && langContainer.children.length === 0) {
|
||||
const allLanguages = Object.keys(languageToFontFamily).sort().map(code => {
|
||||
let name = code;
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
|
||||
name = displayNames.of(code) || code;
|
||||
} catch (e) {
|
||||
console.warn(`Failed to get language name for ${code}`, e);
|
||||
}
|
||||
return { code, name: `${name} (${code})` };
|
||||
});
|
||||
|
||||
const renderLanguages = (filter = '') => {
|
||||
langContainer.innerHTML = '';
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
|
||||
allLanguages.forEach(lang => {
|
||||
if (lang.name.toLowerCase().includes(lowerFilter) || lang.code.toLowerCase().includes(lowerFilter)) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'flex items-center hover:bg-gray-700 p-1 rounded';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = lang.code;
|
||||
checkbox.id = `lang-${lang.code}`;
|
||||
checkbox.className = 'w-4 h-4 text-indigo-600 bg-gray-600 border-gray-500 rounded focus:ring-indigo-500 ring-offset-gray-800';
|
||||
if (lang.code === 'eng') checkbox.checked = true;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `lang-${lang.code}`;
|
||||
label.className = 'ml-2 text-sm font-medium text-gray-300 w-full cursor-pointer';
|
||||
label.textContent = lang.name;
|
||||
|
||||
checkbox.addEventListener('change', updateButtonText);
|
||||
|
||||
wrapper.appendChild(checkbox);
|
||||
wrapper.appendChild(label);
|
||||
langContainer.appendChild(wrapper);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
renderLanguages();
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const filter = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
const items = langContainer.children;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i] as HTMLElement;
|
||||
const text = item.textContent?.toLowerCase() || '';
|
||||
if (text.includes(filter)) {
|
||||
item.classList.remove('hidden');
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dropdownBtn && dropdownContent) {
|
||||
dropdownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownContent.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!dropdownBtn.contains(e.target as Node) && !dropdownContent.contains(e.target as Node)) {
|
||||
dropdownContent.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateButtonText() {
|
||||
const checkboxes = langContainer?.querySelectorAll('input[type="checkbox"]:checked');
|
||||
const count = checkboxes?.length || 0;
|
||||
if (count === 0) {
|
||||
if (dropdownText) dropdownText.textContent = 'Select Languages';
|
||||
} else if (count === 1) {
|
||||
const text = checkboxes[0].nextElementSibling.textContent;
|
||||
if (dropdownText) dropdownText.textContent = text || '1 Language Selected';
|
||||
} else {
|
||||
if (dropdownText) dropdownText.textContent = `${count} Languages Selected`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const switchToUpload = () => {
|
||||
uploadPanel.classList.remove('hidden');
|
||||
textPanel.classList.add('hidden');
|
||||
@@ -155,6 +315,19 @@ export async function setupTxtToPdfTool() {
|
||||
uploadBtn.addEventListener('click', switchToUpload);
|
||||
textBtn.addEventListener('click', switchToText);
|
||||
|
||||
const pageSizeSelect = document.getElementById('page-size') as HTMLSelectElement;
|
||||
const customSizeContainer = document.getElementById('custom-size-container');
|
||||
|
||||
if (pageSizeSelect && customSizeContainer) {
|
||||
pageSizeSelect.addEventListener('change', () => {
|
||||
if (pageSizeSelect.value === 'Custom') {
|
||||
customSizeContainer.classList.remove('hidden');
|
||||
} else {
|
||||
customSizeContainer.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
if (processBtn) {
|
||||
processBtn.onclick = txtToPdf;
|
||||
@@ -167,25 +340,41 @@ export async function txtToPdf() {
|
||||
|
||||
showLoader('Creating PDF...');
|
||||
try {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const fontFamilyKey = document.getElementById('font-family').value;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const fontSize = parseInt(document.getElementById('font-size').value) || 12;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const pageSizeKey = document.getElementById('page-size').value;
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const colorHex = document.getElementById('text-color').value;
|
||||
const selectedLanguages: string[] = [];
|
||||
const langContainer = document.getElementById('language-list-container');
|
||||
if (langContainer) {
|
||||
const checkboxes = langContainer.querySelectorAll('input[type="checkbox"]:checked');
|
||||
checkboxes.forEach((cb) => {
|
||||
selectedLanguages.push((cb as HTMLInputElement).value);
|
||||
});
|
||||
}
|
||||
if (selectedLanguages.length === 0) selectedLanguages.push('eng'); // Fallback
|
||||
|
||||
const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement)?.value) || 12;
|
||||
const pageSizeKey = (document.getElementById('page-size') as HTMLSelectElement)?.value;
|
||||
const orientation = (document.getElementById('page-orientation') as HTMLSelectElement)?.value || 'portrait';
|
||||
const colorHex = (document.getElementById('text-color') as HTMLInputElement)?.value;
|
||||
|
||||
let customWidth: number | undefined;
|
||||
let customHeight: number | undefined;
|
||||
if (pageSizeKey === 'Custom') {
|
||||
customWidth = parseInt((document.getElementById('custom-width') as HTMLInputElement)?.value) || 595;
|
||||
customHeight = parseInt((document.getElementById('custom-height') as HTMLInputElement)?.value) || 842;
|
||||
}
|
||||
|
||||
if (isUploadMode && state.files.length > 0) {
|
||||
if (state.files.length === 1) {
|
||||
const file = state.files[0];
|
||||
const text = await file.text();
|
||||
const text = (await file.text()).normalize('NFC');
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
selectedLanguages,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
colorHex,
|
||||
orientation,
|
||||
customWidth,
|
||||
customHeight
|
||||
);
|
||||
const baseName = file.name.replace(/\.txt$/i, '');
|
||||
downloadFile(
|
||||
@@ -197,13 +386,16 @@ export async function txtToPdf() {
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const file of state.files) {
|
||||
const text = await file.text();
|
||||
const text = (await file.text()).normalize('NFC');
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
selectedLanguages,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
colorHex,
|
||||
orientation,
|
||||
customWidth,
|
||||
customHeight
|
||||
);
|
||||
const baseName = file.name.replace(/\.txt$/i, '');
|
||||
zip.file(`${baseName}.pdf`, pdfBytes);
|
||||
@@ -213,8 +405,7 @@ export async function txtToPdf() {
|
||||
downloadFile(zipBlob, 'text-to-pdf.zip');
|
||||
}
|
||||
} else {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const text = document.getElementById('text-input').value;
|
||||
const text = ((document.getElementById('text-input') as HTMLTextAreaElement)?.value || '').normalize('NFC');
|
||||
if (!text.trim()) {
|
||||
showAlert('Input Required', 'Please enter some text to convert.');
|
||||
hideLoader();
|
||||
@@ -223,10 +414,13 @@ export async function txtToPdf() {
|
||||
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
selectedLanguages,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
colorHex,
|
||||
orientation,
|
||||
customWidth,
|
||||
customHeight
|
||||
);
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
|
||||
Reference in New Issue
Block a user