feat: enhance PDF tools with new features and UI improvements
- Added 'Extract Attachments' and 'Edit Attachments' functionalities to manage embedded files in PDFs. - Introduced new splitting options: by bookmarks and by a specified number of pages (N times). - Updated the user interface to include new options and improved layout for better usability. - Enhanced the GitHub link display with dynamic star count retrieval. - Bumped version to 1.4.0 and updated footer to reflect the new version. - Refactored existing code for better maintainability and added new TypeScript definitions for the new features.
This commit is contained in:
@@ -272,67 +272,130 @@ export async function compress() {
|
||||
const legacySettings = settings[level].legacy;
|
||||
|
||||
try {
|
||||
const originalFile = state.files[0];
|
||||
const arrayBuffer = await readFileAsArrayBuffer(originalFile);
|
||||
if (state.files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one PDF file.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
let resultBytes;
|
||||
let usedMethod;
|
||||
if (state.files.length === 1) {
|
||||
const originalFile = state.files[0];
|
||||
const arrayBuffer = await readFileAsArrayBuffer(originalFile);
|
||||
|
||||
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
|
||||
);
|
||||
let resultBytes;
|
||||
let usedMethod;
|
||||
|
||||
if (vectorResultBytes.length < originalFile.size) {
|
||||
resultBytes = vectorResultBytes;
|
||||
usedMethod = 'Vector (Automatic)';
|
||||
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 {
|
||||
showAlert('Vector failed to reduce size. Trying Photon...', 'info');
|
||||
showLoader('Running Automatic (Photon fallback)...');
|
||||
resultBytes = await performLegacyCompression(
|
||||
showLoader('Running Automatic (Vector first)...');
|
||||
const vectorResultBytes = await performSmartCompression(
|
||||
arrayBuffer,
|
||||
legacySettings
|
||||
smartSettings
|
||||
);
|
||||
usedMethod = 'Photon (Automatic)';
|
||||
|
||||
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;
|
||||
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}%).`
|
||||
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 {
|
||||
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'
|
||||
);
|
||||
}
|
||||
showLoader('Compressing multiple PDFs...');
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
let totalOriginalSize = 0;
|
||||
let totalCompressedSize = 0;
|
||||
|
||||
downloadFile(
|
||||
new Blob([resultBytes], { type: 'application/pdf' }),
|
||||
'compressed-final.pdf'
|
||||
);
|
||||
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',
|
||||
|
||||
205
src/js/logic/edit-attachments.ts
Normal file
205
src/js/logic/edit-attachments.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
|
||||
let currentAttachments: Array<{ name: string; index: number; size: number }> = [];
|
||||
let attachmentsToRemove: Set<number> = new Set();
|
||||
let attachmentsToReplace: Map<number, File> = new Map();
|
||||
|
||||
export async function setupEditAttachmentsTool() {
|
||||
const optionsDiv = document.getElementById('edit-attachments-options');
|
||||
if (!optionsDiv || !state.pdfDoc) return;
|
||||
|
||||
optionsDiv.classList.remove('hidden');
|
||||
await loadAttachmentsList();
|
||||
}
|
||||
|
||||
async function loadAttachmentsList() {
|
||||
const attachmentsList = document.getElementById('attachments-list');
|
||||
if (!attachmentsList || !state.pdfDoc) return;
|
||||
|
||||
attachmentsList.innerHTML = '';
|
||||
currentAttachments = [];
|
||||
attachmentsToRemove.clear();
|
||||
attachmentsToReplace.clear();
|
||||
|
||||
try {
|
||||
// Get embedded files from PDF
|
||||
const embeddedFiles = state.pdfDoc.context.enumerateIndirectObjects()
|
||||
.filter(([ref, obj]: any) => {
|
||||
const dict = obj instanceof PDFLibDocument.context.dict ? obj : null;
|
||||
return dict && dict.get('Type')?.toString() === '/Filespec';
|
||||
});
|
||||
|
||||
if (embeddedFiles.length === 0) {
|
||||
attachmentsList.innerHTML = '<p class="text-gray-400 text-center py-4">No attachments found in this PDF.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for (const [ref, fileSpec] of embeddedFiles) {
|
||||
try {
|
||||
const fileSpecDict = fileSpec as any;
|
||||
const fileName = fileSpecDict.get('UF')?.decodeText() ||
|
||||
fileSpecDict.get('F')?.decodeText() ||
|
||||
`attachment-${index + 1}`;
|
||||
|
||||
const ef = fileSpecDict.get('EF');
|
||||
let fileSize = 0;
|
||||
if (ef) {
|
||||
const fRef = ef.get('F') || ef.get('UF');
|
||||
if (fRef) {
|
||||
const fileStream = state.pdfDoc.context.lookup(fRef);
|
||||
if (fileStream) {
|
||||
fileSize = (fileStream as any).getContents().length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentAttachments.push({ name: fileName, index, size: fileSize });
|
||||
|
||||
const attachmentDiv = document.createElement('div');
|
||||
attachmentDiv.className = 'flex items-center justify-between p-3 bg-gray-800 rounded-lg border border-gray-700';
|
||||
attachmentDiv.dataset.attachmentIndex = index.toString();
|
||||
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'flex-1';
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'text-white font-medium block';
|
||||
nameSpan.textContent = fileName;
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'text-gray-400 text-sm';
|
||||
sizeSpan.textContent = `${Math.round(fileSize / 1024)} KB`;
|
||||
infoDiv.append(nameSpan, sizeSpan);
|
||||
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'flex items-center gap-2';
|
||||
|
||||
// Remove button
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.title = 'Remove attachment';
|
||||
removeBtn.onclick = () => {
|
||||
attachmentsToRemove.add(index);
|
||||
attachmentDiv.classList.add('opacity-50', 'line-through');
|
||||
removeBtn.disabled = true;
|
||||
};
|
||||
|
||||
// Replace button
|
||||
const replaceBtn = document.createElement('button');
|
||||
replaceBtn.className = 'btn bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1 rounded text-sm';
|
||||
replaceBtn.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4"></i>';
|
||||
replaceBtn.title = 'Replace attachment';
|
||||
replaceBtn.onclick = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
attachmentsToReplace.set(index, file);
|
||||
nameSpan.textContent = `${fileName} → ${file.name}`;
|
||||
nameSpan.classList.add('text-yellow-400');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
actionsDiv.append(replaceBtn, removeBtn);
|
||||
attachmentDiv.append(infoDiv, actionsDiv);
|
||||
attachmentsList.appendChild(attachmentDiv);
|
||||
index++;
|
||||
} catch (e) {
|
||||
console.warn(`Failed to process attachment ${index}:`, e);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading attachments:', e);
|
||||
showAlert('Error', 'Failed to load attachments from PDF.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function editAttachments() {
|
||||
if (!state.pdfDoc) {
|
||||
showAlert('Error', 'PDF is not loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Updating attachments...');
|
||||
try {
|
||||
// Create a new PDF document
|
||||
const newPdfDoc = await PDFLibDocument.create();
|
||||
|
||||
// Copy all pages
|
||||
const pages = await newPdfDoc.copyPages(state.pdfDoc, state.pdfDoc.getPageIndices());
|
||||
pages.forEach((page: any) => newPdfDoc.addPage(page));
|
||||
|
||||
// Handle attachments
|
||||
const embeddedFiles = state.pdfDoc.context.enumerateIndirectObjects()
|
||||
.filter(([ref, obj]: any) => {
|
||||
const dict = obj instanceof PDFLibDocument.context.dict ? obj : null;
|
||||
return dict && dict.get('Type')?.toString() === '/Filespec';
|
||||
});
|
||||
|
||||
let attachmentIndex = 0;
|
||||
for (const [ref, fileSpec] of embeddedFiles) {
|
||||
if (attachmentsToRemove.has(attachmentIndex)) {
|
||||
attachmentIndex++;
|
||||
continue; // Skip removed attachments
|
||||
}
|
||||
|
||||
if (attachmentsToReplace.has(attachmentIndex)) {
|
||||
// Replace attachment
|
||||
const replacementFile = attachmentsToReplace.get(attachmentIndex)!;
|
||||
const fileBytes = await readFileAsArrayBuffer(replacementFile);
|
||||
await newPdfDoc.attach(fileBytes as ArrayBuffer, replacementFile.name, {
|
||||
mimeType: replacementFile.type || 'application/octet-stream',
|
||||
description: `Attached file: ${replacementFile.name}`,
|
||||
creationDate: new Date(),
|
||||
modificationDate: new Date(replacementFile.lastModified),
|
||||
});
|
||||
} else {
|
||||
// Keep existing attachment - copy it
|
||||
try {
|
||||
const fileSpecDict = fileSpec as any;
|
||||
const fileName = fileSpecDict.get('UF')?.decodeText() ||
|
||||
fileSpecDict.get('F')?.decodeText() ||
|
||||
`attachment-${attachmentIndex + 1}`;
|
||||
|
||||
const ef = fileSpecDict.get('EF');
|
||||
if (ef) {
|
||||
const fRef = ef.get('F') || ef.get('UF');
|
||||
if (fRef) {
|
||||
const fileStream = state.pdfDoc.context.lookup(fRef);
|
||||
if (fileStream) {
|
||||
const fileData = (fileStream as any).getContents();
|
||||
await newPdfDoc.attach(fileData, fileName, {
|
||||
mimeType: 'application/octet-stream',
|
||||
description: `Attached file: ${fileName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to copy attachment ${attachmentIndex}:`, e);
|
||||
}
|
||||
}
|
||||
attachmentIndex++;
|
||||
}
|
||||
|
||||
const pdfBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
`edited-attachments-${state.files[0].name}`
|
||||
);
|
||||
showAlert('Success', 'Attachments updated successfully!');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Failed to edit attachments.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
86
src/js/logic/extract-attachments.ts
Normal file
86
src/js/logic/extract-attachments.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export async function extractAttachments() {
|
||||
if (state.files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Extracting attachments...');
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
let totalAttachments = 0;
|
||||
|
||||
for (const file of state.files) {
|
||||
const pdfBytes = await readFileAsArrayBuffer(file);
|
||||
const pdfDoc = await PDFLibDocument.load(pdfBytes as ArrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
|
||||
const embeddedFiles = pdfDoc.context.enumerateIndirectObjects()
|
||||
.filter(([ref, obj]: any) => {
|
||||
// obj must be a PDFDict
|
||||
if (obj && typeof obj.get === 'function') {
|
||||
const type = obj.get('Type');
|
||||
return type && type.toString() === '/Filespec';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (embeddedFiles.length === 0) {
|
||||
console.warn(`No attachments found in ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract attachments
|
||||
const baseName = file.name.replace(/\.pdf$/i, '');
|
||||
for (let i = 0; i < embeddedFiles.length; i++) {
|
||||
try {
|
||||
const [ref, fileSpec] = embeddedFiles[i];
|
||||
const fileSpecDict = fileSpec as any;
|
||||
|
||||
// Get attachment name
|
||||
const fileName = fileSpecDict.get('UF')?.decodeText() ||
|
||||
fileSpecDict.get('F')?.decodeText() ||
|
||||
`attachment-${i + 1}`;
|
||||
|
||||
// Get embedded file stream
|
||||
const ef = fileSpecDict.get('EF');
|
||||
if (ef) {
|
||||
const fRef = ef.get('F') || ef.get('UF');
|
||||
if (fRef) {
|
||||
const fileStream = pdfDoc.context.lookup(fRef);
|
||||
if (fileStream) {
|
||||
const fileData = (fileStream as any).getContents();
|
||||
zip.file(`${baseName}_${fileName}`, fileData);
|
||||
totalAttachments++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to extract attachment ${i} from ${file.name}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAttachments === 0) {
|
||||
showAlert('No Attachments', 'No attachments were found in the selected PDF(s).');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'extracted-attachments.zip');
|
||||
showAlert('Success', `Extracted ${totalAttachments} attachment(s) successfully!`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Failed to extract attachments. The PDF may not contain attachments or may be corrupted.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +1,151 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
import { jpgToPdf } from './jpg-to-pdf.js';
|
||||
import { pngToPdf } from './png-to-pdf.js';
|
||||
import { webpToPdf } from './webp-to-pdf.js';
|
||||
import { bmpToPdf } from './bmp-to-pdf.js';
|
||||
import { tiffToPdf } from './tiff-to-pdf.js';
|
||||
import { svgToPdf } from './svg-to-pdf.js';
|
||||
import { heicToPdf } from './heic-to-pdf.js';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
|
||||
/**
|
||||
* Converts any image into a standard, web-friendly JPEG. Loses transparency.
|
||||
* @param {Uint8Array} imageBytes The raw bytes of the image file.
|
||||
* @returns {Promise<Uint8Array>} A promise that resolves with sanitized JPEG bytes.
|
||||
*/
|
||||
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.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob(
|
||||
async (jpegBlob) => {
|
||||
if (!jpegBlob)
|
||||
return reject(new Error('Canvas to JPEG conversion failed.'));
|
||||
resolve(new Uint8Array(await jpegBlob.arrayBuffer()));
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('File could not be loaded as an image.'));
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts any image into a standard PNG. Preserves transparency.
|
||||
* @param {Uint8Array} imageBytes The raw bytes of the image file.
|
||||
* @returns {Promise<Uint8Array>} A promise that resolves with sanitized PNG bytes.
|
||||
*/
|
||||
function sanitizeImageAsPng(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 (pngBlob) => {
|
||||
if (!pngBlob)
|
||||
return reject(new Error('Canvas to PNG conversion failed.'));
|
||||
resolve(new Uint8Array(await pngBlob.arrayBuffer()));
|
||||
}, 'image/png');
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('File could not be loaded as an image.'));
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export async function imageToPdf() {
|
||||
if (state.files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one image file.');
|
||||
return;
|
||||
}
|
||||
showLoader('Converting images to PDF...');
|
||||
|
||||
const filesByType: { [key: string]: File[] } = {};
|
||||
|
||||
for (const file of state.files) {
|
||||
const type = file.type || '';
|
||||
if (!filesByType[type]) {
|
||||
filesByType[type] = [];
|
||||
}
|
||||
filesByType[type].push(file);
|
||||
}
|
||||
|
||||
const types = Object.keys(filesByType);
|
||||
if (types.length === 1) {
|
||||
const type = types[0];
|
||||
const originalFiles = state.files;
|
||||
|
||||
if (type === 'image/jpeg' || type === 'image/jpg') {
|
||||
state.files = filesByType[type] as File[];
|
||||
await jpgToPdf();
|
||||
} else if (type === 'image/png') {
|
||||
state.files = filesByType[type] as File[];
|
||||
await pngToPdf();
|
||||
} else if (type === 'image/webp') {
|
||||
state.files = filesByType[type] as File[];
|
||||
await webpToPdf();
|
||||
} else if (type === 'image/bmp') {
|
||||
state.files = filesByType[type] as File[];
|
||||
await bmpToPdf();
|
||||
} else if (type === 'image/tiff' || type === 'image/tif') {
|
||||
state.files = filesByType[type] as File[];
|
||||
await tiffToPdf();
|
||||
} else if (type === 'image/svg+xml') {
|
||||
state.files = filesByType[type] as File[];
|
||||
await svgToPdf();
|
||||
} else {
|
||||
const firstFile = filesByType[type][0];
|
||||
if (firstFile.name.toLowerCase().endsWith('.heic') ||
|
||||
firstFile.name.toLowerCase().endsWith('.heif')) {
|
||||
state.files = filesByType[type] as File[];
|
||||
await heicToPdf();
|
||||
} else {
|
||||
showLoader('Converting images to PDF...');
|
||||
try {
|
||||
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
|
||||
for (const file of filesByType[type]) {
|
||||
const imageBitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageBitmap.width;
|
||||
canvas.height = imageBitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imageBitmap, 0, 0);
|
||||
|
||||
const pngBlob = await new Promise<Blob>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
);
|
||||
const pngBytes = await pngBlob.arrayBuffer();
|
||||
const pngImage = await pdfDoc.embedPng(pngBytes);
|
||||
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
|
||||
page.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pngImage.width,
|
||||
height: pngImage.height,
|
||||
});
|
||||
imageBitmap.close();
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'from-images.pdf'
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Failed to convert images to PDF.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.files = originalFiles;
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Converting mixed image types to PDF...');
|
||||
try {
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
|
||||
const imageList = document.getElementById('image-list');
|
||||
const sortedFiles = Array.from(imageList.children)
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'dataset' does not exist on type 'Element... Remove this comment to see the full error message
|
||||
.map((li) => state.files.find((f) => f.name === li.dataset.fileName))
|
||||
.filter(Boolean);
|
||||
const sortedFiles = imageList
|
||||
? Array.from(imageList.children)
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'dataset' does not exist on type 'Element... Remove this comment to see the full error message
|
||||
.map((li) => state.files.find((f) => f.name === li.dataset.fileName))
|
||||
.filter(Boolean)
|
||||
: state.files;
|
||||
|
||||
const qualityInput = document.getElementById('image-pdf-quality') as HTMLInputElement;
|
||||
const quality = qualityInput ? Math.max(0.3, Math.min(1.0, parseFloat(qualityInput.value))) : 0.9;
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||
const type = file.type || '';
|
||||
let image;
|
||||
|
||||
if (file.type === 'image/jpeg') {
|
||||
try {
|
||||
image = await pdfDoc.embedJpg(fileBuffer as Uint8Array);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Direct JPG embedding failed for ${file.name}, sanitizing to JPG...`
|
||||
);
|
||||
const sanitizedBytes = await sanitizeImageAsJpeg(fileBuffer);
|
||||
image = await pdfDoc.embedJpg(sanitizedBytes as Uint8Array);
|
||||
}
|
||||
} else if (file.type === 'image/png') {
|
||||
try {
|
||||
image = await pdfDoc.embedPng(fileBuffer as Uint8Array);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Direct PNG embedding failed for ${file.name}, sanitizing to PNG...`
|
||||
);
|
||||
const sanitizedBytes = await sanitizeImageAsPng(fileBuffer);
|
||||
image = await pdfDoc.embedPng(sanitizedBytes as Uint8Array);
|
||||
}
|
||||
} else {
|
||||
// For WebP and other types, convert to PNG to preserve transparency
|
||||
console.warn(
|
||||
`Unsupported type "${file.type}" for ${file.name}, converting to PNG...`
|
||||
try {
|
||||
const imageBitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageBitmap.width;
|
||||
canvas.height = imageBitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imageBitmap, 0, 0);
|
||||
const jpegBlob = await new Promise<Blob>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', quality)
|
||||
);
|
||||
const sanitizedBytes = await sanitizeImageAsPng(fileBuffer);
|
||||
image = await pdfDoc.embedPng(sanitizedBytes as Uint8Array);
|
||||
}
|
||||
const jpegBytes = new Uint8Array(await jpegBlob.arrayBuffer());
|
||||
image = await pdfDoc.embedJpg(jpegBytes);
|
||||
imageBitmap.close();
|
||||
|
||||
const page = pdfDoc.addPage([image.width, image.height]);
|
||||
page.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
const page = pdfDoc.addPage([image.width, image.height]);
|
||||
page.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Failed to process ${file.name}:`, e);
|
||||
// Continue with next file
|
||||
}
|
||||
}
|
||||
|
||||
if (pdfDoc.getPageCount() === 0) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { addHeaderFooter } from './add-header-footer.js';
|
||||
import { imageToPdf } from './image-to-pdf.js';
|
||||
import { changePermissions } from './change-permissions.js';
|
||||
import { pdfToMarkdown } from './pdf-to-markdown.js';
|
||||
import { txtToPdf } from './txt-to-pdf.js';
|
||||
import { txtToPdf, setupTxtToPdfTool } from './txt-to-pdf.js';
|
||||
import { invertColors } from './invert-colors.js';
|
||||
// import { viewMetadata } from './view-metadata.js';
|
||||
import { reversePages } from './reverse-pages.js';
|
||||
@@ -63,6 +63,8 @@ import {
|
||||
import { alternateMerge, setupAlternateMergeTool } from './alternate-merge.js';
|
||||
import { linearizePdf } from './linearize.js';
|
||||
import { addAttachments, setupAddAttachmentsTool } from './add-attachments.js';
|
||||
import { extractAttachments } from './extract-attachments.js';
|
||||
import { editAttachments, setupEditAttachmentsTool } from './edit-attachments.js';
|
||||
import { sanitizePdf } from './sanitize-pdf.js';
|
||||
import { removeRestrictions } from './remove-restrictions.js';
|
||||
|
||||
@@ -96,7 +98,7 @@ export const toolLogic = {
|
||||
'image-to-pdf': imageToPdf,
|
||||
'change-permissions': changePermissions,
|
||||
'pdf-to-markdown': pdfToMarkdown,
|
||||
'txt-to-pdf': txtToPdf,
|
||||
'txt-to-pdf': { process: txtToPdf, setup: setupTxtToPdfTool },
|
||||
'invert-colors': invertColors,
|
||||
'reverse-pages': reversePages,
|
||||
// 'md-to-pdf': mdToPdf,
|
||||
@@ -138,5 +140,10 @@ export const toolLogic = {
|
||||
process: addAttachments,
|
||||
setup: setupAddAttachmentsTool,
|
||||
},
|
||||
'extract-attachments': extractAttachments,
|
||||
'edit-attachments': {
|
||||
process: editAttachments,
|
||||
setup: setupEditAttachmentsTool,
|
||||
},
|
||||
'sanitize-pdf': sanitizePdf,
|
||||
};
|
||||
|
||||
815
src/js/logic/pdf-multi-tool.ts
Normal file
815
src/js/logic/pdf-multi-tool.ts
Normal file
@@ -0,0 +1,815 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { degrees, PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import JSZip from 'jszip';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
interface PageData {
|
||||
pdfIndex: number;
|
||||
pageIndex: number;
|
||||
rotation: number;
|
||||
visualRotation: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
pdfDoc: PDFLibDocument;
|
||||
originalPageIndex: number;
|
||||
}
|
||||
|
||||
let allPages: PageData[] = [];
|
||||
let selectedPages: Set<number> = new Set();
|
||||
let currentPdfDocs: PDFLibDocument[] = [];
|
||||
let splitMarkers: Set<number> = new Set();
|
||||
let isRendering = false;
|
||||
let renderCancelled = false;
|
||||
|
||||
const pageCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||
|
||||
type Snapshot = { allPages: PageData[]; selectedPages: number[]; splitMarkers: number[] };
|
||||
const undoStack: Snapshot[] = [];
|
||||
const redoStack: Snapshot[] = [];
|
||||
|
||||
function snapshot() {
|
||||
const snap: Snapshot = {
|
||||
allPages: allPages.map(p => ({ ...p })),
|
||||
selectedPages: Array.from(selectedPages),
|
||||
splitMarkers: Array.from(splitMarkers),
|
||||
};
|
||||
undoStack.push(snap);
|
||||
redoStack.length = 0;
|
||||
}
|
||||
|
||||
function restore(snap: Snapshot) {
|
||||
allPages = snap.allPages.map(p => ({ ...p }));
|
||||
selectedPages = new Set(snap.selectedPages);
|
||||
splitMarkers = new Set(snap.splitMarkers);
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
function showModal(title: string, message: string, type: 'info' | 'error' | 'success' = 'info') {
|
||||
const modal = document.getElementById('modal');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalMessage = document.getElementById('modal-message');
|
||||
const modalIcon = document.getElementById('modal-icon');
|
||||
|
||||
if (!modal || !modalTitle || !modalMessage || !modalIcon) return;
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.textContent = message;
|
||||
|
||||
const iconMap = {
|
||||
info: 'info',
|
||||
error: 'alert-circle',
|
||||
success: 'check-circle'
|
||||
};
|
||||
const colorMap = {
|
||||
info: 'text-blue-400',
|
||||
error: 'text-red-400',
|
||||
success: 'text-green-400'
|
||||
};
|
||||
|
||||
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
|
||||
modal.classList.remove('hidden');
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
const modal = document.getElementById('modal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showLoading(current: number, total: number) {
|
||||
const loader = document.getElementById('loading-overlay');
|
||||
const progress = document.getElementById('loading-progress');
|
||||
const text = document.getElementById('loading-text');
|
||||
|
||||
if (!loader || !progress || !text) return;
|
||||
|
||||
loader.classList.remove('hidden');
|
||||
const percentage = Math.round((current / total) * 100);
|
||||
progress.style.width = `${percentage}%`;
|
||||
text.textContent = `Rendering pages... ${current} of ${total}`;
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
const loader = document.getElementById('loading-overlay');
|
||||
if (loader) loader.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeTool();
|
||||
});
|
||||
|
||||
function initializeTool() {
|
||||
createIcons({ icons });
|
||||
|
||||
document.getElementById('close-tool-btn')?.addEventListener('click', () => {
|
||||
window.location.href = '../../index.html';
|
||||
});
|
||||
|
||||
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) {
|
||||
showModal('Please Wait', 'Pages are still being rendered. Please wait...', 'info');
|
||||
return;
|
||||
}
|
||||
document.getElementById('pdf-file-input')?.click();
|
||||
});
|
||||
|
||||
document.getElementById('pdf-file-input')?.addEventListener('change', handlePdfUpload);
|
||||
document.getElementById('insert-pdf-input')?.addEventListener('change', handleInsertPdf);
|
||||
|
||||
document.getElementById('bulk-rotate-left-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
snapshot();
|
||||
bulkRotate(-90);
|
||||
});
|
||||
document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
snapshot();
|
||||
bulkRotate(90);
|
||||
});
|
||||
document.getElementById('bulk-delete-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
snapshot();
|
||||
bulkDelete();
|
||||
});
|
||||
document.getElementById('bulk-duplicate-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
snapshot();
|
||||
bulkDuplicate();
|
||||
});
|
||||
document.getElementById('bulk-split-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
snapshot();
|
||||
bulkSplit();
|
||||
});
|
||||
document.getElementById('bulk-download-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
bulkDownload();
|
||||
});
|
||||
document.getElementById('select-all-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
selectAll();
|
||||
});
|
||||
document.getElementById('deselect-all-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
deselectAll();
|
||||
});
|
||||
document.getElementById('export-pdf-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
downloadAll();
|
||||
});
|
||||
document.getElementById('add-blank-page-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
snapshot();
|
||||
addBlankPage();
|
||||
});
|
||||
document.getElementById('undo-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
const last = undoStack.pop();
|
||||
if (last) {
|
||||
const current: Snapshot = {
|
||||
allPages: allPages.map(p => ({ ...p })),
|
||||
selectedPages: Array.from(selectedPages),
|
||||
splitMarkers: Array.from(splitMarkers),
|
||||
};
|
||||
redoStack.push(current);
|
||||
restore(last);
|
||||
}
|
||||
});
|
||||
document.getElementById('redo-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) return;
|
||||
const next = redoStack.pop();
|
||||
if (next) {
|
||||
const current: Snapshot = {
|
||||
allPages: allPages.map(p => ({ ...p })),
|
||||
selectedPages: Array.from(selectedPages),
|
||||
splitMarkers: Array.from(splitMarkers),
|
||||
};
|
||||
undoStack.push(current);
|
||||
restore(next);
|
||||
}
|
||||
});
|
||||
document.getElementById('reset-btn')?.addEventListener('click', () => {
|
||||
if (isRendering) {
|
||||
renderCancelled = true;
|
||||
setTimeout(() => resetAll(), 100);
|
||||
} else {
|
||||
resetAll();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal close button
|
||||
document.getElementById('modal-close-btn')?.addEventListener('click', hideModal);
|
||||
document.getElementById('modal')?.addEventListener('click', (e) => {
|
||||
if (e.target === document.getElementById('modal')) {
|
||||
hideModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
if (uploadArea) {
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('border-indigo-500');
|
||||
});
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('border-indigo-500');
|
||||
});
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('border-indigo-500');
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type === 'application/pdf');
|
||||
if (files.length > 0) {
|
||||
loadPdfs(files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show upload area initially
|
||||
document.getElementById('upload-area')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
snapshot();
|
||||
allPages = [];
|
||||
selectedPages.clear();
|
||||
splitMarkers.clear();
|
||||
currentPdfDocs = [];
|
||||
pageCanvasCache.clear();
|
||||
renderCancelled = false;
|
||||
isRendering = false;
|
||||
updatePageDisplay();
|
||||
document.getElementById('upload-area')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function handlePdfUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length > 0) {
|
||||
await loadPdfs(files);
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function loadPdfs(files: File[]) {
|
||||
if (isRendering) {
|
||||
showModal('Please Wait', 'Pages are still being rendered. Please wait...', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
if (uploadArea) uploadArea.classList.add('hidden');
|
||||
|
||||
isRendering = true;
|
||||
renderCancelled = false;
|
||||
let totalPages = 0;
|
||||
let currentPage = 0;
|
||||
|
||||
try {
|
||||
// First pass: count total pages
|
||||
const pdfDocs: PDFLibDocument[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
pdfDocs.push(pdfDoc);
|
||||
totalPages += pdfDoc.getPageCount();
|
||||
} catch (e) {
|
||||
console.error(`Failed to load PDF ${file.name}:`, e);
|
||||
showModal('Error', `Failed to load ${file.name}. The file may be corrupted.`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: render pages
|
||||
for (const pdfDoc of pdfDocs) {
|
||||
if (renderCancelled) break;
|
||||
|
||||
currentPdfDocs.push(pdfDoc);
|
||||
const numPages = pdfDoc.getPageCount();
|
||||
|
||||
for (let i = 0; i < numPages; i++) {
|
||||
if (renderCancelled) break;
|
||||
|
||||
currentPage++;
|
||||
showLoading(currentPage, totalPages);
|
||||
await renderPage(pdfDoc, i, currentPdfDocs.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!renderCancelled) {
|
||||
setupSortable();
|
||||
createIcons({ icons });
|
||||
}
|
||||
} finally {
|
||||
hideLoading();
|
||||
isRendering = false;
|
||||
if (renderCancelled) {
|
||||
renderCancelled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheKey(pdfIndex: number, pageIndex: number): string {
|
||||
// Removed rotation from cache key - canvas is always rendered at 0 degrees
|
||||
return `${pdfIndex}-${pageIndex}`;
|
||||
}
|
||||
|
||||
async function renderPage(pdfDoc: PDFLibDocument, pageIndex: number, pdfIndex: number) {
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = getCacheKey(pdfIndex, pageIndex);
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
if (pageCanvasCache.has(cacheKey)) {
|
||||
canvas = pageCanvasCache.get(cacheKey)!;
|
||||
} else {
|
||||
// Render page preview at 0 degrees rotation using pdfjs
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise;
|
||||
const page = await pdf.getPage(pageIndex + 1);
|
||||
|
||||
// Always render at 0 rotation - visual rotation is applied via CSS
|
||||
const viewport = page.getViewport({ scale: 0.5, rotation: 0 });
|
||||
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
background: 'white',
|
||||
canvas
|
||||
}).promise;
|
||||
|
||||
// Cache the canvas
|
||||
pageCanvasCache.set(cacheKey, canvas);
|
||||
}
|
||||
|
||||
const pageData: PageData = {
|
||||
pdfIndex,
|
||||
pageIndex,
|
||||
rotation: 0, // Actual rotation to apply when saving PDF
|
||||
visualRotation: 0, // Visual rotation for display only
|
||||
canvas,
|
||||
pdfDoc,
|
||||
originalPageIndex: pageIndex,
|
||||
};
|
||||
|
||||
allPages.push(pageData);
|
||||
createPageCard(pageData, allPages.length - 1);
|
||||
}
|
||||
|
||||
function createPageCard(pageData: PageData, index: number) {
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move';
|
||||
card.dataset.pageIndex = index.toString();
|
||||
if (selectedPages.has(index)) {
|
||||
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||
}
|
||||
|
||||
// Page preview
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative';
|
||||
preview.style.minHeight = '160px';
|
||||
preview.style.maxHeight = '256px';
|
||||
|
||||
const previewCanvas = pageData.canvas;
|
||||
previewCanvas.className = 'max-w-full max-h-full object-contain';
|
||||
|
||||
// Apply visual rotation using CSS transform
|
||||
previewCanvas.style.transform = `rotate(${pageData.visualRotation}deg)`;
|
||||
|
||||
// Adjust container dimensions based on rotation
|
||||
if (pageData.visualRotation === 90 || pageData.visualRotation === 270) {
|
||||
preview.style.aspectRatio = `${previewCanvas.height} / ${previewCanvas.width}`;
|
||||
} else {
|
||||
preview.style.aspectRatio = `${previewCanvas.width} / ${previewCanvas.height}`;
|
||||
}
|
||||
|
||||
preview.appendChild(previewCanvas);
|
||||
|
||||
// Page info
|
||||
const info = document.createElement('div');
|
||||
info.className = 'text-xs text-gray-400 text-center mb-2';
|
||||
info.textContent = `Page ${index + 1}`;
|
||||
|
||||
// Actions toolbar
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity';
|
||||
|
||||
// Select checkbox
|
||||
const selectBtn = document.createElement('button');
|
||||
selectBtn.className = 'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
|
||||
selectBtn.innerHTML = selectedPages.has(index)
|
||||
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
|
||||
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||
selectBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelectOptimized(index);
|
||||
};
|
||||
|
||||
// Rotate button
|
||||
const rotateBtn = document.createElement('button');
|
||||
rotateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>';
|
||||
rotateBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
rotatePage(index, 90);
|
||||
};
|
||||
const rotateLeftBtn = document.createElement('button');
|
||||
rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>';
|
||||
rotateLeftBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
rotatePage(index, -90);
|
||||
};
|
||||
|
||||
// Duplicate button
|
||||
const duplicateBtn = document.createElement('button');
|
||||
duplicateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||
duplicateBtn.innerHTML = '<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>';
|
||||
duplicateBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
snapshot();
|
||||
duplicatePage(index);
|
||||
};
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>';
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
snapshot();
|
||||
deletePage(index);
|
||||
};
|
||||
|
||||
// Insert PDF button
|
||||
const insertBtn = document.createElement('button');
|
||||
insertBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||
insertBtn.innerHTML = '<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>';
|
||||
insertBtn.title = 'Insert PDF after this page';
|
||||
insertBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
snapshot();
|
||||
insertPdfAfter(index);
|
||||
};
|
||||
|
||||
// Split button
|
||||
const splitBtn = document.createElement('button');
|
||||
splitBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||
splitBtn.innerHTML = '<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>';
|
||||
splitBtn.title = 'Toggle split after this page';
|
||||
splitBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
snapshot();
|
||||
toggleSplitMarker(index);
|
||||
renderSplitMarkers();
|
||||
};
|
||||
|
||||
actions.append(rotateLeftBtn, rotateBtn, duplicateBtn, insertBtn, splitBtn, deleteBtn);
|
||||
card.append(preview, info, actions, selectBtn);
|
||||
pagesContainer.appendChild(card);
|
||||
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function setupSortable() {
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
Sortable.create(pagesContainer, {
|
||||
animation: 150,
|
||||
handle: '.cursor-move',
|
||||
onEnd: (evt) => {
|
||||
const oldIndex = evt.oldIndex!;
|
||||
const newIndex = evt.newIndex!;
|
||||
if (oldIndex !== newIndex) {
|
||||
const [moved] = allPages.splice(oldIndex, 1);
|
||||
allPages.splice(newIndex, 0, moved);
|
||||
updatePageNumbers();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Optimized selection that only updates the specific card
|
||||
function toggleSelectOptimized(index: number) {
|
||||
if (selectedPages.has(index)) {
|
||||
selectedPages.delete(index);
|
||||
} else {
|
||||
selectedPages.add(index);
|
||||
}
|
||||
|
||||
// Only update the specific card instead of re-rendering everything
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
const card = pagesContainer.children[index] as HTMLElement;
|
||||
if (!card) return;
|
||||
|
||||
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]');
|
||||
if (!selectBtn) return;
|
||||
|
||||
if (selectedPages.has(index)) {
|
||||
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
||||
} else {
|
||||
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||
}
|
||||
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedPages.clear();
|
||||
allPages.forEach((_, index) => selectedPages.add(index));
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedPages.clear();
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
// Instant rotation - just update visual rotation, no re-rendering
|
||||
function rotatePage(index: number, delta: number) {
|
||||
snapshot();
|
||||
|
||||
const pageData = allPages[index];
|
||||
pageData.visualRotation = (pageData.visualRotation + delta + 360) % 360;
|
||||
pageData.rotation = (pageData.rotation + delta + 360) % 360;
|
||||
|
||||
// Just update the specific card's transform
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
const card = pagesContainer.children[index] as HTMLElement;
|
||||
if (!card) return;
|
||||
|
||||
const canvas = card.querySelector('canvas');
|
||||
const preview = card.querySelector('.bg-white');
|
||||
|
||||
if (canvas && preview) {
|
||||
canvas.style.transform = `rotate(${pageData.visualRotation}deg)`;
|
||||
|
||||
// Adjust container aspect ratio
|
||||
if (pageData.visualRotation === 90 || pageData.visualRotation === 270) {
|
||||
(preview as HTMLElement).style.aspectRatio = `${canvas.height} / ${canvas.width}`;
|
||||
} else {
|
||||
(preview as HTMLElement).style.aspectRatio = `${canvas.width} / ${canvas.height}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function duplicatePage(index: number) {
|
||||
const originalPageData = allPages[index];
|
||||
const originalCanvas = originalPageData.canvas;
|
||||
|
||||
// Create a new canvas and copy content
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.width = originalCanvas.width;
|
||||
newCanvas.height = originalCanvas.height;
|
||||
|
||||
const newContext = newCanvas.getContext('2d');
|
||||
if (newContext) {
|
||||
newContext.drawImage(originalCanvas, 0, 0);
|
||||
}
|
||||
|
||||
const newPageData: PageData = {
|
||||
...originalPageData,
|
||||
canvas: newCanvas,
|
||||
};
|
||||
|
||||
const newIndex = index + 1;
|
||||
allPages.splice(newIndex, 0, newPageData);
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
function deletePage(index: number) {
|
||||
allPages.splice(index, 1);
|
||||
selectedPages.delete(index);
|
||||
// Update selected indices
|
||||
const newSelected = new Set<number>();
|
||||
selectedPages.forEach(i => {
|
||||
if (i > index) newSelected.add(i - 1);
|
||||
else if (i < index) newSelected.add(i);
|
||||
});
|
||||
selectedPages = newSelected;
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
async function insertPdfAfter(index: number) {
|
||||
document.getElementById('insert-pdf-input')?.click();
|
||||
(window as any).__insertAfterIndex = index;
|
||||
}
|
||||
|
||||
async function handleInsertPdf(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const insertAfterIndex = (window as any).__insertAfterIndex;
|
||||
if (insertAfterIndex === undefined) return;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
currentPdfDocs.push(pdfDoc);
|
||||
|
||||
const numPages = pdfDoc.getPageCount();
|
||||
const newPages: PageData[] = [];
|
||||
for (let i = 0; i < numPages; i++) {
|
||||
// Use the existing renderPage function, which adds to allPages
|
||||
await renderPage(pdfDoc, i, currentPdfDocs.length - 1);
|
||||
// Move the newly added page data to the temporary array
|
||||
newPages.push(allPages.pop()!);
|
||||
}
|
||||
|
||||
// Insert pages after the specified index
|
||||
allPages.splice(insertAfterIndex + 1, 0, ...newPages);
|
||||
updatePageDisplay();
|
||||
} catch (e) {
|
||||
console.error('Failed to insert PDF:', e);
|
||||
showModal('Error', 'Failed to insert PDF. The file may be corrupted.', 'error');
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function toggleSplitMarker(index: number) {
|
||||
if (splitMarkers.has(index)) splitMarkers.delete(index);
|
||||
else splitMarkers.add(index);
|
||||
}
|
||||
|
||||
function renderSplitMarkers() {
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
// Remove all existing split markers
|
||||
pagesContainer.querySelectorAll('.split-marker').forEach(m => m.remove());
|
||||
|
||||
// Add split markers between cards
|
||||
Array.from(pagesContainer.children).forEach((cardEl, i) => {
|
||||
if (splitMarkers.has(i)) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
||||
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
||||
(cardEl as HTMLElement).appendChild(marker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addBlankPage() {
|
||||
// Create a blank page
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 595;
|
||||
canvas.height = 842;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, 595, 842);
|
||||
}
|
||||
|
||||
const blankPageData: PageData = {
|
||||
pdfIndex: -1,
|
||||
pageIndex: -1,
|
||||
rotation: 0,
|
||||
visualRotation: 0,
|
||||
canvas,
|
||||
pdfDoc: null as any,
|
||||
originalPageIndex: -1,
|
||||
};
|
||||
|
||||
allPages.push(blankPageData);
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
// Instant bulk rotation - just update visual rotation
|
||||
function bulkRotate(delta: number) {
|
||||
if (selectedPages.size === 0) {
|
||||
showModal('No Selection', 'Please select pages to rotate.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedPages.forEach(index => {
|
||||
const pageData = allPages[index];
|
||||
pageData.visualRotation = (pageData.visualRotation + delta + 360) % 360;
|
||||
pageData.rotation = (pageData.rotation + delta + 360) % 360;
|
||||
});
|
||||
|
||||
// Update display for all rotated pages
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
if (selectedPages.size === 0) {
|
||||
showModal('No Selection', 'Please select pages to delete.', 'info');
|
||||
return;
|
||||
}
|
||||
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
||||
indices.forEach(index => allPages.splice(index, 1));
|
||||
selectedPages.clear();
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
function bulkDuplicate() {
|
||||
if (selectedPages.size === 0) {
|
||||
showModal('No Selection', 'Please select pages to duplicate.', 'info');
|
||||
return;
|
||||
}
|
||||
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
||||
indices.forEach(index => {
|
||||
duplicatePage(index);
|
||||
});
|
||||
selectedPages.clear();
|
||||
updatePageDisplay();
|
||||
}
|
||||
|
||||
function bulkSplit() {
|
||||
if (selectedPages.size === 0) {
|
||||
showModal('No Selection', 'Please select pages to split.', 'info');
|
||||
return;
|
||||
}
|
||||
const indices = Array.from(selectedPages);
|
||||
downloadPagesAsPdf(indices, 'selected-pages.pdf');
|
||||
}
|
||||
|
||||
async function bulkDownload() {
|
||||
if (selectedPages.size === 0) {
|
||||
showModal('No Selection', 'Please select pages to download.', 'info');
|
||||
return;
|
||||
}
|
||||
const indices = Array.from(selectedPages);
|
||||
await downloadPagesAsPdf(indices, 'selected-pages.pdf');
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
const indices = Array.from({ length: allPages.length }, (_, i) => i);
|
||||
await downloadPagesAsPdf(indices, 'all-pages.pdf');
|
||||
}
|
||||
|
||||
async function downloadPagesAsPdf(indices: number[], filename: string) {
|
||||
try {
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
|
||||
for (const index of indices) {
|
||||
const pageData = allPages[index];
|
||||
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
||||
// Copy page from original PDF
|
||||
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]);
|
||||
const page = newPdf.addPage(copiedPage);
|
||||
|
||||
if (pageData.rotation !== 0) {
|
||||
const currentRotation = page.getRotation().angle;
|
||||
page.setRotation(degrees(currentRotation + pageData.rotation));
|
||||
}
|
||||
} else {
|
||||
newPdf.addPage([595, 842]);
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('Failed to create PDF:', e);
|
||||
showModal('Error', 'Failed to create PDF.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePageDisplay() {
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
pagesContainer.innerHTML = '';
|
||||
allPages.forEach((pageData, index) => {
|
||||
createPageCard(pageData, index);
|
||||
});
|
||||
setupSortable();
|
||||
renderSplitMarkers();
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function updatePageNumbers() {
|
||||
updatePageDisplay();
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export async function pdfToJpg() {
|
||||
).promise;
|
||||
const zip = new JSZip();
|
||||
|
||||
const qualityInput = document.getElementById('jpg-quality') as HTMLInputElement;
|
||||
const quality = qualityInput ? parseFloat(qualityInput.value) : 0.9;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
@@ -23,7 +26,7 @@ export async function pdfToJpg() {
|
||||
await page.render({ canvasContext: context, viewport: viewport }).promise;
|
||||
|
||||
const blob = await new Promise((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', 0.9)
|
||||
canvas.toBlob(resolve, 'image/jpeg', quality)
|
||||
);
|
||||
zip.file(`page_${i}.jpg`, blob as Blob);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,13 @@ export async function pdfToPng() {
|
||||
await readFileAsArrayBuffer(state.files[0])
|
||||
).promise;
|
||||
const zip = new JSZip();
|
||||
|
||||
const qualityInput = document.getElementById('png-quality') as HTMLInputElement;
|
||||
const scale = qualityInput ? parseFloat(qualityInput.value) : 2.0;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
@@ -19,8 +19,11 @@ export async function pdfToWebp() {
|
||||
canvas.width = viewport.width;
|
||||
const context = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: context, viewport: viewport }).promise;
|
||||
const qualityInput = document.getElementById('webp-quality') as HTMLInputElement;
|
||||
const quality = qualityInput ? parseFloat(qualityInput.value) : 0.9;
|
||||
|
||||
const blob = await new Promise((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/webp', 0.9)
|
||||
canvas.toBlob(resolve, 'image/webp', quality)
|
||||
);
|
||||
zip.file(`page_${i}.webp`, blob as Blob);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import JSZip from 'jszip';
|
||||
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
|
||||
// Track if visual selector has been rendered to avoid duplicates
|
||||
let visualSelectorRendered = false;
|
||||
|
||||
async function renderVisualSelector() {
|
||||
@@ -89,6 +88,9 @@ export function setupSplitTool() {
|
||||
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;
|
||||
|
||||
@@ -106,7 +108,10 @@ export function setupSplitTool() {
|
||||
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');
|
||||
@@ -119,6 +124,34 @@ export function setupSplitTool() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -185,10 +218,81 @@ export async function split() {
|
||||
// @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) {
|
||||
if (uniqueIndices.length === 0 && splitMode !== 'bookmarks' && splitMode !== 'n-times') {
|
||||
throw new Error('No pages were selected for splitting.');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, hexToRgb } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import {
|
||||
PDFDocument as PDFLibDocument,
|
||||
@@ -8,64 +10,88 @@ import {
|
||||
PageSizes,
|
||||
} from 'pdf-lib';
|
||||
|
||||
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 text = document.getElementById('text-input').value;
|
||||
if (!text.trim()) {
|
||||
showAlert('Input Required', 'Please enter some text to convert.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
function sanitizeTextForPdf(text: string): string {
|
||||
return text
|
||||
.split('')
|
||||
.map((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
|
||||
// @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 textColor = hexToRgb(colorHex);
|
||||
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts[fontFamilyKey]);
|
||||
const pageSize = PageSizes[pageSizeKey];
|
||||
const margin = 72; // 1 inch
|
||||
|
||||
let page = pdfDoc.addPage(pageSize);
|
||||
let { width, height } = page.getSize();
|
||||
const textWidth = width - margin * 2;
|
||||
const lineHeight = fontSize * 1.3;
|
||||
let y = height - margin;
|
||||
|
||||
const paragraphs = text.split('\n');
|
||||
for (const paragraph of paragraphs) {
|
||||
const words = paragraph.split(' ');
|
||||
let currentLine = '';
|
||||
for (const word of words) {
|
||||
const testLine =
|
||||
currentLine.length > 0 ? `${currentLine} ${word}` : word;
|
||||
if (font.widthOfTextAtSize(testLine, fontSize) <= textWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
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),
|
||||
});
|
||||
y -= lineHeight;
|
||||
currentLine = word;
|
||||
}
|
||||
if (code === 0x20 || code === 0x09 || code === 0x0A) {
|
||||
return char;
|
||||
}
|
||||
if (currentLine.length > 0) {
|
||||
|
||||
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,
|
||||
fontSize: number,
|
||||
pageSizeKey: string,
|
||||
colorHex: string
|
||||
): Promise<Uint8Array> {
|
||||
const sanitizedText = sanitizeTextForPdf(text);
|
||||
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts[fontFamilyKey]);
|
||||
const pageSize = PageSizes[pageSizeKey];
|
||||
const margin = 72;
|
||||
const textColor = hexToRgb(colorHex);
|
||||
|
||||
let page = pdfDoc.addPage(pageSize);
|
||||
let { width, height } = page.getSize();
|
||||
const textWidth = width - margin * 2;
|
||||
const lineHeight = fontSize * 1.3;
|
||||
let y = height - margin;
|
||||
|
||||
const paragraphs = sanitizedText.split('\n');
|
||||
for (const paragraph of paragraphs) {
|
||||
const words = paragraph.split(' ');
|
||||
let currentLine = '';
|
||||
for (const word of words) {
|
||||
const testLine =
|
||||
currentLine.length > 0 ? `${currentLine} ${word}` : word;
|
||||
if (font.widthOfTextAtSize(testLine, fontSize) <= textWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (y < margin + lineHeight) {
|
||||
page = pdfDoc.addPage(pageSize);
|
||||
y = page.getHeight() - margin;
|
||||
@@ -78,14 +104,130 @@ export async function txtToPdf() {
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
y -= lineHeight;
|
||||
currentLine = word;
|
||||
}
|
||||
}
|
||||
if (currentLine.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),
|
||||
});
|
||||
y -= lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'text-document.pdf'
|
||||
);
|
||||
return await pdfDoc.save();
|
||||
}
|
||||
|
||||
export async function setupTxtToPdfTool() {
|
||||
const uploadBtn = document.getElementById('txt-mode-upload-btn');
|
||||
const textBtn = document.getElementById('txt-mode-text-btn');
|
||||
const uploadPanel = document.getElementById('txt-upload-panel');
|
||||
const textPanel = document.getElementById('txt-text-panel');
|
||||
|
||||
if (!uploadBtn || !textBtn || !uploadPanel || !textPanel) return;
|
||||
|
||||
const switchToUpload = () => {
|
||||
uploadPanel.classList.remove('hidden');
|
||||
textPanel.classList.add('hidden');
|
||||
uploadBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
uploadBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
textBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
textBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
};
|
||||
|
||||
const switchToText = () => {
|
||||
uploadPanel.classList.add('hidden');
|
||||
textPanel.classList.remove('hidden');
|
||||
textBtn.classList.add('bg-indigo-600', 'text-white');
|
||||
textBtn.classList.remove('bg-gray-700', 'text-gray-300');
|
||||
uploadBtn.classList.remove('bg-indigo-600', 'text-white');
|
||||
uploadBtn.classList.add('bg-gray-700', 'text-gray-300');
|
||||
};
|
||||
|
||||
uploadBtn.addEventListener('click', switchToUpload);
|
||||
textBtn.addEventListener('click', switchToText);
|
||||
}
|
||||
|
||||
export async function txtToPdf() {
|
||||
const uploadPanel = document.getElementById('txt-upload-panel');
|
||||
const isUploadMode = !uploadPanel?.classList.contains('hidden');
|
||||
|
||||
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;
|
||||
|
||||
if (isUploadMode && state.files.length > 0) {
|
||||
if (state.files.length === 1) {
|
||||
const file = state.files[0];
|
||||
const text = await file.text();
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
);
|
||||
const baseName = file.name.replace(/\.txt$/i, '');
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
`${baseName}.pdf`
|
||||
);
|
||||
} else {
|
||||
showLoader('Creating PDFs and ZIP archive...');
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const file of state.files) {
|
||||
const text = await file.text();
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
);
|
||||
const baseName = file.name.replace(/\.txt$/i, '');
|
||||
zip.file(`${baseName}.pdf`, pdfBytes);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
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;
|
||||
if (!text.trim()) {
|
||||
showAlert('Input Required', 'Please enter some text to convert.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfBytes = await createPdfFromText(
|
||||
text,
|
||||
fontFamilyKey,
|
||||
fontSize,
|
||||
pageSizeKey,
|
||||
colorHex
|
||||
);
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'text-document.pdf'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Failed to create PDF from text.');
|
||||
|
||||
Reference in New Issue
Block a user