Compare commits

...
10 Commits
Author SHA1 Message Date
abdullahalam123 e0381e6aa0 Release v1.10.4 2025-12-05 00:38:15 +05:30
abdullahalam123 c682f9d9b3 fix(config): Remove leading slash from merge PDF tool path
- Remove leading slash from merge-pdf.html path in tools configuration
- Ensure consistent path resolution with BASE_URL environment variable
- Fix asset path handling for subdirectory hosting compatibility
2025-12-05 00:38:08 +05:30
abdullahalam123 a976295b4c Release v1.10.3 2025-12-04 23:57:42 +05:30
abdullahalam123 af01a041bf Release v1.10.2 2025-12-04 23:53:31 +05:30
abdullahalam123 b279c05281 feat: Add subdirectory hosting support and fix asset path resolution
- Update README with comprehensive subdirectory hosting instructions and BASE_URL configuration guide
- Convert absolute asset paths to relative paths in index.html for proper subdirectory deployment
- Update all worker script imports to use relative paths instead of absolute paths
- Fix favicon and image references to work correctly when hosted in nested directories
- Normalize whitespace and formatting across worker files for consistency
- Update vite.config.ts to properly handle BASE_URL configuration for subdirectory deployments
- Ensure all tool pages and logic files maintain compatibility with subdirectory hosting
- Enable BentoPDF to be deployed at any URL path (e.g., example.com/tools/bentopdf/) without breaking asset loading
2025-12-04 23:53:00 +05:30
abdullahalam123 19425d98f9 feat: Add JPG to PDF conversion tool with dedicated page and logic. 2025-12-04 21:21:34 +05:30
abdullahalam123 62d1b9ada2 feat: add new Edit PDF tool with dedicated page and logic, and update build configuration 2025-12-04 15:29:01 +05:30
abdullahalam123 9620084789 feat: Add dedicated compress PDF page and reimplement compression logic. 2025-12-04 14:32:37 +05:30
abdullahalam123 6676fe9f89 feat: Reimplement PDF splitting functionality on a new dedicated page. 2025-12-04 14:02:20 +05:30
abdullahalam123 fb3063324c feat: implement dedicated merge PDF tool page with new logic and UI structure 2025-12-04 12:26:57 +05:30
47 changed files with 5181 additions and 3175 deletions
+25 -1
View File
@@ -249,9 +249,33 @@ npm run package
# Serve the dist folder
npx serve dist
The website can be accessible at: http://localhost:3000/
```
The website can be accessible at: ```http://localhost:3000/```
**Subdirectory Hosting:**
BentoPDF can also be hosted from a subdirectory (e.g., `example.com/tools/bentopdf/`):
```bash
# Example:
# 1. Build the app with the specific BASE_URL. BASE_URL must have a trailing and leading slash. The BASE_URL can be any url of your choice. Here we are using /tools/bentopdf/ as an example.
BASE_URL=/tools/bentopdf/ npm run build
# 2. Create the nested directory structure inside serve-test (or any folder of your choice for local testing. In case of production, create the nested directory structure inside the root directory)
mkdir -p serve-test/tools/bentopdf
# 3. Copy all files from the 'dist' folder into that nested directory
cp -r dist/* serve-test/tools/bentopdf/
# 4. Serve the 'serve-test' folder
npx serve serve-test
```
The website can be accessible at: ```http://localhost:3000/tools/bentopdf/```
The `npm run package` command creates a `dist-{version}.zip` file that you can use for self-hosting.
+8 -8
View File
@@ -5,11 +5,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BentoPDF - The Privacy First PDF Toolkit</title>
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link rel="icon" type="image/png" href="/images/favicon.png" />
<link rel="apple-touch-icon" href="/images/favicon.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link href="/src/css/styles.css" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="images/favicon.svg" />
<link rel="icon" type="image/png" href="images/favicon.png" />
<link rel="apple-touch-icon" href="images/favicon.png" />
<link rel="icon" href="favicon.ico" sizes="any" />
<link href="src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased">
@@ -502,7 +502,7 @@
<!-- GDPR Compliance -->
<div class="flex flex-col items-center text-center">
<div class="w-20 h-20 md:w-24 md:h-24 rounded-full bg-blue-600 flex items-center justify-center mb-4">
<img src="/images/gdpr.svg" alt="GDPR compliance" class="w-full h-full" />
<img src="images/gdpr.svg" alt="GDPR compliance" class="w-full h-full" />
</div>
<h3 class="text-lg md:text-xl font-bold text-white mb-3">
GDPR compliance
@@ -515,7 +515,7 @@
<div class="flex flex-col items-center text-center">
<div class="w-20 h-20 md:w-24 md:h-24 rounded-full bg-blue-600 flex items-center justify-center mb-4">
<img src="/images/ccpa.svg" alt="CCPA compliance" class="w-full h-full" />
<img src="images/ccpa.svg" alt="CCPA compliance" class="w-full h-full" />
</div>
<h3 class="text-lg md:text-xl font-bold text-white mb-3">
CCPA compliance
@@ -528,7 +528,7 @@
<div class="flex flex-col items-center text-center">
<div class="w-20 h-20 md:w-24 md:h-24 rounded-full bg-blue-600 flex items-center justify-center mb-4">
<img src="/images/hipaa.svg" alt="HIPAA compliance" class="w-full h-full" />
<img src="images/hipaa.svg" alt="HIPAA compliance" class="w-full h-full" />
</div>
<h3 class="text-lg md:text-xl font-bold text-white mb-3">
HIPAA compliance
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "bento-pdf",
"private": true,
"version": "1.10.1",
"version": "1.10.4",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,4 +1,4 @@
self.importScripts('/coherentpdf.browser.min.js');
self.importScripts('../coherentpdf.browser.min.js');
function parsePageRange(rangeString, totalPages) {
const pages = new Set();
+1 -1
View File
@@ -1,4 +1,4 @@
self.importScripts('/coherentpdf.browser.min.js');
self.importScripts('../coherentpdf.browser.min.js');
self.onmessage = function (e) {
const { command, files } = e.data;
+15 -15
View File
@@ -1,9 +1,9 @@
self.importScripts('/coherentpdf.browser.min.js');
self.importScripts('../coherentpdf.browser.min.js');
function getAttachmentsFromPDFInWorker(fileBuffer, fileName) {
try {
const uint8Array = new Uint8Array(fileBuffer);
let pdf;
try {
pdf = coherentpdf.fromMemory(uint8Array, '');
@@ -40,8 +40,8 @@ function getAttachmentsFromPDFInWorker(fileBuffer, fileName) {
attachments.push({
index: i,
name: String(name),
page: Number(page),
name: String(name),
page: Number(page),
data: buffer
});
} catch (error) {
@@ -73,7 +73,7 @@ function getAttachmentsFromPDFInWorker(fileBuffer, fileName) {
function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove) {
try {
const uint8Array = new Uint8Array(fileBuffer);
let pdf;
try {
pdf = coherentpdf.fromMemory(uint8Array, '');
@@ -89,28 +89,28 @@ function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove)
coherentpdf.startGetAttachments(pdf);
const attachmentCount = coherentpdf.numberGetAttachments();
const attachmentsToKeep = [];
for (let i = 0; i < attachmentCount; i++) {
if (!attachmentsToRemove.includes(i)) {
const name = coherentpdf.getAttachmentName(i);
const page = coherentpdf.getAttachmentPage(i);
const data = coherentpdf.getAttachmentData(i);
const dataCopy = new Uint8Array(data.length);
dataCopy.set(new Uint8Array(data));
attachmentsToKeep.push({
name: String(name),
page: Number(page),
data: dataCopy
attachmentsToKeep.push({
name: String(name),
page: Number(page),
data: dataCopy
});
}
}
coherentpdf.endGetAttachments();
coherentpdf.removeAttachedFiles(pdf);
for (const attachment of attachmentsToKeep) {
if (attachment.page === 0) {
coherentpdf.attachFileFromMemory(attachment.data, attachment.name, pdf);
@@ -119,7 +119,7 @@ function editAttachmentsInPDFInWorker(fileBuffer, fileName, attachmentsToRemove)
}
}
}
const modifiedBytes = coherentpdf.toMemory(pdf, false, true);
coherentpdf.deletePdf(pdf);
+4 -4
View File
@@ -1,15 +1,15 @@
self.importScripts('/coherentpdf.browser.min.js');
self.importScripts('../coherentpdf.browser.min.js');
function extractAttachmentsFromPDFsInWorker(fileBuffers, fileNames) {
try {
const allAttachments = [];
const totalFiles = fileBuffers.length;
for (let i = 0; i < totalFiles; i++) {
const buffer = fileBuffers[i];
const fileName = fileNames[i];
const uint8Array = new Uint8Array(buffer);
let pdf;
try {
pdf = coherentpdf.fromMemory(uint8Array, '');
@@ -73,7 +73,7 @@ function extractAttachmentsFromPDFsInWorker(fileBuffers, fileNames) {
});
return;
}
const response = {
status: 'success',
attachments: []
+1 -1
View File
@@ -1,4 +1,4 @@
self.importScripts('/coherentpdf.browser.min.js');
self.importScripts('../coherentpdf.browser.min.js');
self.onmessage = function (e) {
const { command, files, jobs } = e.data;
+1
View File
@@ -7,6 +7,7 @@ export const singlePdfLoadTools = [
'pdf-to-png',
'pdf-to-webp',
'pdf-to-greyscale',
'edit',
'edit-metadata',
'remove-metadata',
+18 -18
View File
@@ -4,38 +4,38 @@ export const categories = [
name: 'Popular Tools',
tools: [
{
href: '/src/pages/pdf-multi-tool.html',
href: import.meta.env.BASE_URL + 'src/pages/pdf-multi-tool.html',
name: 'PDF Multi Tool',
icon: 'pencil-ruler',
subtitle: 'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface.',
},
{
id: 'merge',
href: import.meta.env.BASE_URL + 'src/pages/merge-pdf.html',
name: 'Merge PDF',
icon: 'combine',
subtitle: 'Combine multiple PDFs into one file. Preserves Bookmarks.',
},
{
id: 'split',
href: import.meta.env.BASE_URL + 'src/pages/split-pdf.html',
name: 'Split PDF',
icon: 'scissors',
subtitle: 'Extract a range of pages into a new PDF.',
},
{
id: 'compress',
href: import.meta.env.BASE_URL + 'src/pages/compress-pdf.html',
name: 'Compress PDF',
icon: 'zap',
subtitle: 'Reduce the file size of your PDF.',
},
{
id: 'edit',
href: import.meta.env.BASE_URL + 'src/pages/edit-pdf.html',
name: 'PDF Editor',
icon: 'pocket-knife',
subtitle:
'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs',
},
{
id: 'jpg-to-pdf',
href: import.meta.env.BASE_URL + 'src/pages/jpg-to-pdf.html',
name: 'JPG to PDF',
icon: 'image-up',
subtitle: 'Create a PDF from one or more JPG images.',
@@ -76,7 +76,7 @@ export const categories = [
name: 'Edit & Annotate',
tools: [
{
id: 'edit',
href: import.meta.env.BASE_URL + 'src/pages/edit-pdf.html',
name: 'PDF Editor',
icon: 'pocket-knife',
subtitle:
@@ -84,13 +84,13 @@ export const categories = [
},
{
// id: 'bookmark-pdf',
href: '/src/pages/bookmark.html',
href: import.meta.env.BASE_URL + 'src/pages/bookmark.html',
name: 'Edit Bookmarks',
icon: 'bookmark',
subtitle: 'Add, edit, import, delete and extract PDF bookmarks.',
},
{
href: '/src/pages/table-of-contents.html',
href: import.meta.env.BASE_URL + 'src/pages/table-of-contents.html',
name: 'Table of Contents',
icon: 'list',
subtitle: 'Generate a table of contents page from PDF bookmarks.',
@@ -138,7 +138,7 @@ export const categories = [
subtitle: 'Draw, type, or upload your signature.',
},
{
href: '/src/pages/add-stamps.html',
href: import.meta.env.BASE_URL + 'src/pages/add-stamps.html',
name: 'Add Stamps',
icon: 'stamp',
subtitle: 'Add image stamps to your PDF using the annotation toolbar.',
@@ -162,7 +162,7 @@ export const categories = [
subtitle: 'Fill in forms directly in the browser. Also supports XFA forms.',
},
{
href: '/src/pages/form-creator.html',
href: import.meta.env.BASE_URL + 'src/pages/form-creator.html',
name: 'Create PDF Form',
icon: 'file-input',
subtitle: 'Create fillable PDF forms with drag-and-drop text fields.',
@@ -185,7 +185,7 @@ export const categories = [
subtitle: 'Convert JPG, PNG, WebP, BMP, TIFF, SVG, HEIC to PDF.',
},
{
id: 'jpg-to-pdf',
href: import.meta.env.BASE_URL + 'src/pages/jpg-to-pdf.html',
name: 'JPG to PDF',
icon: 'image-up',
subtitle: 'Create a PDF from one or more JPG images.',
@@ -233,7 +233,7 @@ export const categories = [
subtitle: 'Convert a plain text file into a PDF.',
},
{
href: '/src/pages/json-to-pdf.html',
href: import.meta.env.BASE_URL + 'src/pages/json-to-pdf.html',
name: 'JSON to PDF',
icon: 'file-code',
subtitle: 'Convert JSON files to PDF format.',
@@ -283,7 +283,7 @@ export const categories = [
subtitle: 'Convert all colors to black and white.',
},
{
href: '/src/pages/pdf-to-json.html',
href: import.meta.env.BASE_URL + 'src/pages/pdf-to-json.html',
name: 'PDF to JSON',
icon: 'file-code',
subtitle: 'Convert PDF files to JSON format.',
@@ -301,7 +301,7 @@ export const categories = [
subtitle: 'Make a PDF searchable and copyable.',
},
{
id: 'merge',
href: import.meta.env.BASE_URL + 'src/pages/merge-pdf.html',
name: 'Merge PDF',
icon: 'combine',
subtitle: 'Combine multiple PDFs into one file.',
@@ -343,7 +343,7 @@ export const categories = [
subtitle: 'View or remove attachments in your PDF.',
},
{
href: '/src/pages/pdf-multi-tool.html',
href: import.meta.env.BASE_URL + 'src/pages/pdf-multi-tool.html',
name: 'PDF Multi Tool',
icon: 'pencil-ruler',
subtitle: 'Full-featured PDF editor with page management.',
@@ -438,7 +438,7 @@ export const categories = [
name: 'Optimize & Repair',
tools: [
{
id: 'compress',
href: import.meta.env.BASE_URL + 'src/pages/compress-pdf.html',
name: 'Compress PDF',
icon: 'zap',
subtitle: 'Reduce the file size of your PDF.',
@@ -469,7 +469,7 @@ export const categories = [
'Remove password protection and security restrictions associated with digitally signed PDF files.',
},
{
href: '/src/pages/repair-pdf.html',
href: import.meta.env.BASE_URL + 'src/pages/repair-pdf.html',
name: 'Repair PDF',
icon: 'wrench',
subtitle: 'Recover data from corrupted or damaged PDF files.',
+6 -40
View File
@@ -624,9 +624,11 @@ async function handleMultiFileUpload(toolId) {
}
}
if (toolId === 'merge') {
toolLogic.merge.setup();
} else if (toolId === 'alternate-merge') {
// if (toolId === 'merge') {
// toolLogic.merge.setup();
// }
if (toolId === 'alternate-merge') {
toolLogic['alternate-merge'].setup();
} else if (toolId === 'image-to-pdf') {
const imageList = document.getElementById('image-list');
@@ -741,7 +743,7 @@ async function handleMultiFileUpload(toolId) {
}
}
if (toolId === 'jpg-to-pdf' || toolId === 'png-to-pdf') {
if (toolId === 'png-to-pdf') {
const optionsDiv = document.getElementById(`${toolId}-options`);
if (optionsDiv) {
optionsDiv.classList.remove('hidden');
@@ -829,42 +831,6 @@ export function setupFileInputHandler(toolId) {
}
};
}
} else if (toolId === 'edit') {
const file = state.files[0];
if (!file) return;
const pdfWrapper = document.getElementById('embed-pdf-wrapper');
const pdfContainer = document.getElementById('embed-pdf-container');
pdfContainer.textContent = ''; // Clear safely
if (state.currentPdfUrl) {
URL.revokeObjectURL(state.currentPdfUrl);
}
pdfWrapper.classList.remove('hidden');
const fileURL = URL.createObjectURL(file);
state.currentPdfUrl = fileURL;
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}',
theme: 'dark',
});
`;
document.head.appendChild(script);
const backBtn = document.getElementById('back-to-grid');
const urlRevoker = () => {
URL.revokeObjectURL(fileURL);
state.currentPdfUrl = null;
backBtn.removeEventListener('click', urlRevoker);
};
backBtn.addEventListener('click', urlRevoker);
}
};
+1 -1
View File
@@ -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[] = [];
+1 -1
View File
@@ -172,7 +172,7 @@ if (saveStampedBtn) {
if (backToToolsBtn) {
backToToolsBtn.addEventListener('click', () => {
window.location.href = '/'
window.location.href = import.meta.env.BASE_URL
})
}
+1 -1
View File
@@ -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');
+2 -2
View File
@@ -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
View 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);
}
});
-404
View File
@@ -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();
}
}
+1 -1
View File
@@ -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
View 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();
}
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -1962,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
})
})
+5 -6
View File
@@ -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
View 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 -2
View File
@@ -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
View 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();
});
}
});
-470
View File
@@ -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');
}
}
+1 -1
View File
@@ -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 -2
View File
@@ -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
})
}
+1 -1
View File
@@ -15,7 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = '/';
window.location.href = import.meta.env.BASE_URL;
});
}
+547
View 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);
}
});
-361
View File
@@ -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 -2
View File
@@ -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;
});
}
+1624 -1769
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -17,18 +17,18 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="/" class="nav-link">Home</a>
<a href="../../index.html" class="nav-link">Home</a>
<a href="/about.html" class="nav-link">About</a>
<a href="/contact.html" class="nav-link">Contact</a>
<a href="/" class="nav-link">All Tools</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
@@ -53,10 +53,10 @@
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="/" class="mobile-nav-link">Home</a>
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="/about.html" class="mobile-nav-link">About</a>
<a href="/contact.html" class="mobile-nav-link">Contact</a>
<a href="/" class="mobile-nav-link">All Tools</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
@@ -113,7 +113,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
+11 -11
View File
@@ -18,18 +18,18 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="/" class="nav-link">Home</a>
<a href="./about.html" class="nav-link">About</a>
<a href="./contact.html" class="nav-link">Contact</a>
<a href="/" class="nav-link">All Tools</a>
<a href="../../index.html" class="nav-link">Home</a>
<a href="../../about.html" class="nav-link">About</a>
<a href="../../contact.html" class="nav-link">Contact</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
@@ -56,10 +56,10 @@
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="/" class="mobile-nav-link">Home</a>
<a href="./about.html" class="mobile-nav-link">About</a>
<a href="./contact.html" class="mobile-nav-link">Contact</a>
<a href="/" class="mobile-nav-link">All Tools</a>
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="../../about.html" class="mobile-nav-link">About</a>
<a href="../../contact.html" class="mobile-nav-link">Contact</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
@@ -421,7 +421,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
+250
View File
@@ -0,0 +1,250 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compress PDF - BentoPDF</title>
<meta name="description"
content="Reduce PDF file size with various compression methods. Free, secure, and runs entirely in your browser.">
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
<link href="../../src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700 sticky top-0 z-30">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="../../index.html" class="nav-link">Home</a>
<a href="./about.html" class="nav-link">About</a>
<a href="./contact.html" class="nav-link">Contact</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<div class="md:hidden flex items-center">
<button id="mobile-menu-button" type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 transition-colors"
aria-controls="mobile-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg id="menu-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="./about.html" class="mobile-nav-link">About</a>
<a href="./contact.html" class="mobile-nav-link">Contact</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
<div id="uploader" class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900">
<div id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl p-8 max-w-2xl w-full text-gray-200 border border-gray-700">
<button id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold">
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer"> Back to Tools </span>
</button>
<h1 class="text-2xl font-bold text-white mb-2">Compress PDF</h1>
<p class="text-gray-400 mb-6">
Reduce file size by choosing the compression method that best suits your document. Supports multiple
PDFs.
</p>
<div id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-96 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
<p class="mb-2 text-sm text-gray-400"><span class="font-semibold">Click to select files</span> or
drag and
drop</p>
<p class="text-xs text-gray-500">One or more PDF files</p>
<p class="text-xs text-gray-500">Your files never leave your device.</p>
</div>
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
accept="application/pdf" multiple>
</div>
<div id="file-controls" class="hidden mt-4 flex gap-2">
<button id="add-more-btn"
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors">
<i data-lucide="plus" class="inline-block w-4 h-4 mr-1"></i>
Add More
</button>
<button id="clear-files-btn"
class="flex-1 bg-red-600 hover:bg-red-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors">
<i data-lucide="trash-2" class="inline-block w-4 h-4 mr-1"></i>
Clear All
</button>
</div>
<div id="file-display-area" class="mt-4 space-y-2"></div>
<div id="compress-options" class="hidden mt-6 space-y-6">
<div>
<label for="compression-level" class="block mb-2 text-sm font-medium text-gray-300">Compression
Level</label>
<select id="compression-level"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500">
<option value="balanced">Balanced (Recommended)</option>
<option value="high-quality">High Quality (Larger file)</option>
<option value="small-size">Smallest Size (Lower quality)</option>
<option value="extreme">Extreme (Very low quality)</option>
</select>
</div>
<div>
<label for="compression-algorithm" class="block mb-2 text-sm font-medium text-gray-300">Compression
Algorithm</label>
<select id="compression-algorithm"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500">
<option value="vector">Vector (For Text Heavy PDF)</option>
<option value="photon">Photon (For Complex Images & Drawings)</option>
</select>
<p class="mt-2 text-xs text-gray-400">
Choose 'Vector' for text based PDFs, or 'Photon' for scanned documents and complex images.
</p>
</div>
<button id="process-btn" class="btn-gradient w-full mt-4">Compress PDF</button>
</div>
</div>
</div>
<div id="loader-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl">
<div class="solid-spinner"></div>
<p id="loader-text" class="text-white text-lg font-medium">
Processing...
</p>
</div>
</div>
<div id="alert-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden">
<div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700">
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">Alert</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200">
OK
</button>
</div>
</div>
<footer class="mt-16 border-t-2 border-gray-700 py-8">
<div class="container mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
&copy; 2025 BentoPDF. All rights reserved.
</p>
<p class="text-gray-500 text-xs mt-2">
Version <span id="app-version"></span>
</p>
</div>
<div>
<h3 class="font-bold text-white mb-4">Company</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../about.html" class="hover:text-indigo-400">About Us</a>
</li>
<li>
<a href="../../faq.html" class="hover:text-indigo-400">FAQ</a>
</li>
<li>
<a href="../../contact.html" class="hover:text-indigo-400">Contact Us</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Legal</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../licensing.html" class="hover:text-indigo-400">Licensing</a>
</li>
<li>
<a href="../../terms.html" class="hover:text-indigo-400">Terms and Conditions</a>
</li>
<li>
<a href="../../privacy.html" class="hover:text-indigo-400">Privacy Policy</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Follow Us</h3>
<div class="flex justify-center md:justify-start space-x-4">
<a href="https://github.com/alam00000/bentopdf" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd" />
</svg>
</a>
<a href="https://discord.gg/AP2Y97juZT" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="Discord">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
<a href="https://www.instagram.com/thebentopdf/" class="text-gray-400 hover:text-indigo-400"
title="Instagram">
<i data-lucide="instagram"></i>
</a>
<a href="https://www.linkedin.com/company/bentopdf/" class="text-gray-400 hover:text-indigo-400"
title="LinkedIn">
<i data-lucide="linkedin"></i>
</a>
<a href="https://x.com/BentoPDF" class="text-gray-400 hover:text-indigo-400"
title="X (Twitter)">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>
<script type="module" src="../js/utils/lucide-init.ts"></script>
<script type="module" src="../js/utils/full-width.ts"></script>
<script type="module" src="../js/utils/simple-mode-footer.ts"></script>
<script type="module" src="../version.ts"></script>
<script type="module" src="../js/logic/compress-pdf-page.ts"></script>
<script type="module" src="../js/mobileMenu.ts"></script>
</body>
</html>
+211
View File
@@ -0,0 +1,211 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PDF Editor - BentoPDF</title>
<meta name="description"
content="Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs. Free, secure, and runs entirely in your browser.">
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
<link href="../../src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700 sticky top-0 z-30">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="../../index.html" class="nav-link">Home</a>
<a href="../../about.html" class="nav-link">About</a>
<a href="../../contact.html" class="nav-link">Contact</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<div class="md:hidden flex items-center">
<button id="mobile-menu-button" type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 transition-colors"
aria-controls="mobile-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg id="menu-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="../../about.html" class="mobile-nav-link">About</a>
<a href="../../contact.html" class="mobile-nav-link">Contact</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
<div id="uploader" class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900">
<div id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl p-8 max-w-2xl w-full text-blue-400 border border-gray-700">
<button id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold">
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer"> Back to Tools </span>
</button>
<h1 class="text-2xl font-bold text-white mb-2">PDF Editor</h1>
<p class="text-gray-400 mb-6">
Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs.
</p>
<div id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-96 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
<p class="mb-2 text-sm text-gray-400"><span class="font-semibold">Click to select a file</span> or
drag and
drop</p>
<p class="text-xs text-gray-500">PDF file</p>
<p class="text-xs text-gray-500">Your files never leave your device.</p>
</div>
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
accept="application/pdf">
</div>
<div id="file-display-area" class="mt-4 space-y-2"></div>
<div id="embed-pdf-wrapper" class="hidden mt-6 w-full h-[75vh] border border-gray-600 rounded-lg">
<div id="embed-pdf-container" class="w-full h-full"></div>
</div>
</div>
</div>
<div id="loader-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl">
<div class="solid-spinner"></div>
<p id="loader-text" class="text-white text-lg font-medium">
Processing...
</p>
</div>
</div>
<div id="alert-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden">
<div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700">
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">Alert</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200">
OK
</button>
</div>
</div>
<footer class="mt-16 border-t-2 border-gray-700 py-8">
<div class="container mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
&copy; 2025 BentoPDF. All rights reserved.
</p>
<p class="text-gray-500 text-xs mt-2">
Version <span id="app-version"></span>
</p>
</div>
<div>
<h3 class="font-bold text-white mb-4">Company</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../about.html" class="hover:text-indigo-400">About Us</a>
</li>
<li>
<a href="../../faq.html" class="hover:text-indigo-400">FAQ</a>
</li>
<li>
<a href="../../contact.html" class="hover:text-indigo-400">Contact Us</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Legal</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../licensing.html" class="hover:text-indigo-400">Licensing</a>
</li>
<li>
<a href="../../terms.html" class="hover:text-indigo-400">Terms and Conditions</a>
</li>
<li>
<a href="../../privacy.html" class="hover:text-indigo-400">Privacy Policy</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Follow Us</h3>
<div class="flex justify-center md:justify-start space-x-4">
<a href="https://github.com/alam00000/bentopdf" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd" />
</svg>
</a>
<a href="https://discord.gg/AP2Y97juZT" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="Discord">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
<a href="https://www.instagram.com/thebentopdf/" class="text-gray-400 hover:text-indigo-400"
title="Instagram">
<i data-lucide="instagram"></i>
</a>
<a href="https://www.linkedin.com/company/bentopdf/" class="text-gray-400 hover:text-indigo-400"
title="LinkedIn">
<i data-lucide="linkedin"></i>
</a>
<a href="https://x.com/BentoPDF" class="text-gray-400 hover:text-indigo-400"
title="X (Twitter)">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>
<script type="module" src="../js/utils/lucide-init.ts"></script>
<script type="module" src="../js/utils/full-width.ts"></script>
<script type="module" src="../js/utils/simple-mode-footer.ts"></script>
<script type="module" src="../version.ts"></script>
<script type="module" src="../js/logic/edit-pdf-page.ts"></script>
<script type="module" src="../js/mobileMenu.ts"></script>
</body>
</html>
+7 -7
View File
@@ -17,18 +17,18 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="/" class="nav-link">Home</a>
<a href="../../index.html" class="nav-link">Home</a>
<a href="/about.html" class="nav-link">About</a>
<a href="/contact.html" class="nav-link">Contact</a>
<a href="/" class="nav-link">All Tools</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
@@ -55,10 +55,10 @@
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="/" class="mobile-nav-link">Home</a>
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="/about.html" class="mobile-nav-link">About</a>
<a href="/contact.html" class="mobile-nav-link">Contact</a>
<a href="/" class="mobile-nav-link">All Tools</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
@@ -327,7 +327,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
+240
View File
@@ -0,0 +1,240 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JPG to PDF - BentoPDF</title>
<meta name="description"
content="Convert one or more JPG images into a single PDF file. Free, secure, and runs entirely in your browser.">
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
<link href="../../src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700 sticky top-0 z-30">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="../../index.html" class="nav-link">Home</a>
<a href="../../about.html" class="nav-link">About</a>
<a href="../../contact.html" class="nav-link">Contact</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<div class="md:hidden flex items-center">
<button id="mobile-menu-button" type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 transition-colors"
aria-controls="mobile-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg id="menu-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="../../about.html" class="mobile-nav-link">About</a>
<a href="../../contact.html" class="mobile-nav-link">Contact</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
<div id="uploader" class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900">
<div id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl p-8 max-w-2xl w-full text-gray-200 border border-gray-700">
<button id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold">
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer"> Back to Tools </span>
</button>
<h1 class="text-2xl font-bold text-white mb-2">JPG to PDF</h1>
<p class="text-gray-400 mb-6">
Convert one or more JPG images into a single PDF file.
</p>
<!-- Drop Zone -->
<div id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-96 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
<p class="mb-2 text-sm text-gray-400"><span class="font-semibold">Click to select a file</span> or
drag and
drop</p>
<p class="text-xs text-gray-500">JPG Images</p>
<p class="text-xs text-gray-500">Your files never leave your device.</p>
</div>
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
multiple accept="image/jpeg, image/jpg">
</div>
<!-- Control buttons -->
<div id="file-controls" class="hidden mt-4 flex gap-3">
<button id="add-more-btn"
class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2">
<i data-lucide="plus"></i> Add More Files
</button>
<button id="clear-files-btn"
class="btn bg-red-600 hover:bg-red-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2">
<i data-lucide="x"></i> Clear All
</button>
</div>
<!-- File List -->
<div id="file-display-area" class="mt-4 space-y-2"></div>
<!-- Options -->
<div id="jpg-to-pdf-options" class="hidden mt-6">
<div class="mb-4">
<label for="jpg-pdf-quality" class="block mb-2 text-sm font-medium text-gray-300"> PDF Quality
</label>
<select id="jpg-pdf-quality"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5">
<option value="high"> High Quality (Larger file) </option>
<option value="medium" selected> Medium Quality (Balanced) </option>
<option value="low"> Low Quality (Smaller file) </option>
</select>
<p class="mt-1 text-xs text-gray-400"> Controls image compression when embedding into PDF </p>
</div>
<button id="process-btn" class="btn-gradient w-full mt-6">Convert to PDF</button>
</div>
</div>
</div>
<!-- Loader Modal -->
<div id="loader-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl">
<div class="solid-spinner"></div>
<p id="loader-text" class="text-white text-lg font-medium">
Processing...
</p>
</div>
</div>
<!-- Alert Modal -->
<div id="alert-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden">
<div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700">
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">Alert</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200">
OK
</button>
</div>
</div>
<footer class="mt-16 border-t-2 border-gray-700 py-8">
<div class="container mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
&copy; 2025 BentoPDF. All rights reserved.
</p>
<p class="text-gray-500 text-xs mt-2">
Version <span id="app-version"></span>
</p>
</div>
<div>
<h3 class="font-bold text-white mb-4">Company</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../about.html" class="hover:text-indigo-400">About Us</a>
</li>
<li>
<a href="../../faq.html" class="hover:text-indigo-400">FAQ</a>
</li>
<li>
<a href="../../contact.html" class="hover:text-indigo-400">Contact Us</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Legal</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../licensing.html" class="hover:text-indigo-400">Licensing</a>
</li>
<li>
<a href="../../terms.html" class="hover:text-indigo-400">Terms and Conditions</a>
</li>
<li>
<a href="../../privacy.html" class="hover:text-indigo-400">Privacy Policy</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Follow Us</h3>
<div class="flex justify-center md:justify-start space-x-4">
<a href="https://github.com/alam00000/bentopdf" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd" />
</svg>
</a>
<a href="https://discord.gg/AP2Y97juZT" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="Discord">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
<a href="https://www.instagram.com/thebentopdf/" class="text-gray-400 hover:text-indigo-400"
title="Instagram">
<i data-lucide="instagram"></i>
</a>
<a href="https://www.linkedin.com/company/bentopdf/" class="text-gray-400 hover:text-indigo-400"
title="LinkedIn">
<i data-lucide="linkedin"></i>
</a>
<a href="https://x.com/BentoPDF" class="text-gray-400 hover:text-indigo-400"
title="X (Twitter)">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>
<script type="module" src="../js/utils/lucide-init.ts"></script>
<script type="module" src="../js/utils/full-width.ts"></script>
<script type="module" src="../js/utils/simple-mode-footer.ts"></script>
<script type="module" src="../version.ts"></script>
<script type="module" src="../js/logic/jpg-to-pdf-page.ts"></script>
<script type="module" src="../js/mobileMenu.ts"></script>
</body>
</html>
+7 -7
View File
@@ -17,18 +17,18 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="/" class="nav-link">Home</a>
<a href="../../index.html" class="nav-link">Home</a>
<a href="/about.html" class="nav-link">About</a>
<a href="/contact.html" class="nav-link">Contact</a>
<a href="/" class="nav-link">All Tools</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
@@ -53,10 +53,10 @@
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="/" class="mobile-nav-link">Home</a>
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="/about.html" class="mobile-nav-link">About</a>
<a href="/contact.html" class="mobile-nav-link">Contact</a>
<a href="/" class="mobile-nav-link">All Tools</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
@@ -113,7 +113,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
+259
View File
@@ -0,0 +1,259 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Merge PDF - BentoPDF</title>
<meta name="description"
content="Combine multiple PDF files into one document or merge specific pages. Free, secure, and runs entirely in your browser.">
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
<link href="../../src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700 sticky top-0 z-30">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="../../index.html" class="nav-link">Home</a>
<a href="../../about.html" class="nav-link">About</a>
<a href="../../contact.html" class="nav-link">Contact</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
<div class="md:hidden flex items-center">
<button id="mobile-menu-button" type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 transition-colors"
aria-controls="mobile-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<!-- Hamburger Icon -->
<svg id="menu-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<!-- Close Icon -->
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="../../about.html" class="mobile-nav-link">About</a>
<a href="../../contact.html" class="mobile-nav-link">Contact</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
<div id="uploader" class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900">
<div id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl p-8 max-w-2xl w-full text-gray-200 border border-gray-700">
<button id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold">
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer"> Back to Tools </span>
</button>
<h1 class="text-2xl font-bold text-white mb-2">Merge PDFs</h1>
<p class="text-gray-400 mb-6">
Combine whole files, or select specific pages to merge into a new document.
</p>
<!-- Drop Zone for Main PDF Upload -->
<div id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-96 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
<p class="mb-2 text-sm text-gray-400"><span class="font-semibold">Click to select a file</span> or drag and
drop</p>
<p class="text-xs text-gray-500">PDFs or Images</p>
<p class="text-xs text-gray-500">Your files never leave your device.</p>
</div>
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" multiple
accept="application/pdf">
</div>
<!-- Control buttons for multi-file uploads -->
<div id="file-controls" class="hidden mt-4 flex gap-3">
<button id="add-more-btn"
class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2">
<i data-lucide="plus"></i> Add More Files
</button>
<button id="clear-files-btn"
class="btn bg-red-600 hover:bg-red-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2">
<i data-lucide="x"></i> Clear All
</button>
</div>
<div id="merge-options" class="hidden mt-6">
<div class="flex gap-2 p-1 rounded-lg bg-gray-900 border border-gray-700 mb-4">
<button id="file-mode-btn" class="flex-1 btn bg-indigo-600 text-white font-semibold py-2 rounded-md">File
Mode</button>
<button id="page-mode-btn" class="flex-1 btn text-gray-300 font-semibold py-2 rounded-md">Page Mode</button>
</div>
<div id="file-mode-panel">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>Click and drag the <i data-lucide="grip-vertical" class="inline-block w-3 h-3"></i> icon to change the
order of the files.</li>
<li>In the "Pages" box for each file, you can specify ranges (e.g., "1-3, 5") to merge only those pages.
</li>
<li>Leave the "Pages" box blank to include all pages from that file.</li>
</ul>
</div>
<ul id="file-list" class="space-y-2"></ul>
</div>
<div id="page-mode-panel" class="hidden">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>All pages from your uploaded PDFs are shown below.</li>
<li>Simply drag and drop the individual page thumbnails to create the exact order you want for your new
file.</li>
</ul>
</div>
<div id="page-merge-preview"
class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-4 p-4 bg-gray-900 rounded-lg border border-gray-700 min-h-[200px]">
</div>
</div>
<button id="process-btn" class="btn-gradient w-full mt-6">Merge PDFs</button>
</div>
</div>
</div>
<!-- Loader Modal -->
<div id="loader-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl">
<div class="solid-spinner"></div>
<p id="loader-text" class="text-white text-lg font-medium">
Processing...
</p>
</div>
</div>
<!-- Alert Modal -->
<div id="alert-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden">
<div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700">
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">Alert</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200">
OK
</button>
</div>
</div>
<footer class="mt-16 border-t-2 border-gray-700 py-8">
<div class="container mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
&copy; 2025 BentoPDF. All rights reserved.
</p>
<p class="text-gray-500 text-xs mt-2">
Version <span id="app-version"></span>
</p>
</div>
<div>
<h3 class="font-bold text-white mb-4">Company</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../about.html" class="hover:text-indigo-400">About Us</a>
</li>
<li>
<a href="../../faq.html" class="hover:text-indigo-400">FAQ</a>
</li>
<li>
<a href="../../contact.html" class="hover:text-indigo-400">Contact Us</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Legal</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../licensing.html" class="hover:text-indigo-400">Licensing</a>
</li>
<li>
<a href="../../terms.html" class="hover:text-indigo-400">Terms and Conditions</a>
</li>
<li>
<a href="../../privacy.html" class="hover:text-indigo-400">Privacy Policy</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Follow Us</h3>
<div class="flex justify-center md:justify-start space-x-4">
<a href="https://github.com/alam00000/bentopdf" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd" />
</svg>
</a>
<a href="https://discord.gg/AP2Y97juZT" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="Discord">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
<a href="https://www.instagram.com/thebentopdf/" class="text-gray-400 hover:text-indigo-400"
title="Instagram">
<i data-lucide="instagram"></i>
</a>
<a href="https://www.linkedin.com/company/bentopdf/" class="text-gray-400 hover:text-indigo-400"
title="LinkedIn">
<i data-lucide="linkedin"></i>
</a>
<a href="https://x.com/BentoPDF" class="text-gray-400 hover:text-indigo-400" title="X (Twitter)">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>
<script type="module" src="../js/utils/lucide-init.ts"></script>
<script type="module" src="../js/utils/full-width.ts"></script>
<script type="module" src="../js/utils/simple-mode-footer.ts"></script>
<script type="module" src="../version.ts"></script>
<script type="module" src="../js/logic/merge-pdf-page.ts"></script>
<script type="module" src="../js/mobileMenu.ts"></script>
</body>
</html>
+2 -2
View File
@@ -46,9 +46,9 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
<span class="text-gray-400 ml-3 text-sm sm:text-base">PDF Multi Tool</span>
</div>
+7 -7
View File
@@ -17,18 +17,18 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="/" class="nav-link">Home</a>
<a href="../../index.html" class="nav-link">Home</a>
<a href="/about.html" class="nav-link">About</a>
<a href="/contact.html" class="nav-link">Contact</a>
<a href="/" class="nav-link">All Tools</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
@@ -53,10 +53,10 @@
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="/" class="mobile-nav-link">Home</a>
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="/about.html" class="mobile-nav-link">About</a>
<a href="/contact.html" class="mobile-nav-link">Contact</a>
<a href="/" class="mobile-nav-link">All Tools</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
@@ -107,7 +107,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
+11 -11
View File
@@ -14,18 +14,18 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="/" class="nav-link">Home</a>
<a href="./about.html" class="nav-link">About</a>
<a href="./contact.html" class="nav-link">Contact</a>
<a href="/" class="nav-link">All Tools</a>
<a href="../../index.html" class="nav-link">Home</a>
<a href="../../about.html" class="nav-link">About</a>
<a href="../../contact.html" class="nav-link">Contact</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
@@ -52,10 +52,10 @@
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="/" class="mobile-nav-link">Home</a>
<a href="./about.html" class="mobile-nav-link">About</a>
<a href="./contact.html" class="mobile-nav-link">Contact</a>
<a href="/" class="mobile-nav-link">All Tools</a>
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="../../about.html" class="mobile-nav-link">About</a>
<a href="../../contact.html" class="mobile-nav-link">Contact</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
@@ -132,7 +132,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
+333
View File
@@ -0,0 +1,333 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Split PDF - BentoPDF</title>
<meta name="description"
content="Extract pages from a PDF using various methods. Free, secure, and runs entirely in your browser.">
<link rel="icon" type="image/png" href="../../images/favicon.svg" />
<link href="../../src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700 sticky top-0 z-30">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="../../index.html" class="nav-link">Home</a>
<a href="../../about.html" class="nav-link">About</a>
<a href="../../contact.html" class="nav-link">Contact</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
<div class="md:hidden flex items-center">
<button id="mobile-menu-button" type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 transition-colors"
aria-controls="mobile-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<!-- Hamburger Icon -->
<svg id="menu-icon" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
<!-- Close Icon -->
<svg id="close-icon" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="../../about.html" class="mobile-nav-link">About</a>
<a href="../../contact.html" class="mobile-nav-link">Contact</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
<div id="uploader" class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900">
<div id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl p-8 max-w-2xl w-full text-gray-200 border border-gray-700">
<button id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold">
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer"> Back to Tools </span>
</button>
<h1 class="text-2xl font-bold text-white mb-2">Split PDF</h1>
<p class="text-gray-400 mb-6">
Extract pages from a PDF using various methods.
</p>
<!-- Drop Zone for Main PDF Upload -->
<div id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-96 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
<p class="mb-2 text-sm text-gray-400"><span class="font-semibold">Click to select a file</span> or
drag and
drop</p>
<p class="text-xs text-gray-500">A single PDF file</p>
<p class="text-xs text-gray-500">Your files never leave your device.</p>
</div>
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
accept="application/pdf">
</div>
<div id="file-display-area" class="mt-4 space-y-2"></div>
<div id="split-options" class="hidden mt-6">
<label for="split-mode" class="block mb-2 text-sm font-medium text-gray-300">Split Mode</label>
<select id="split-mode"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 mb-4">
<option value="range">Extract by Page Range (Default)</option>
<option value="even-odd">Split by Even/Odd Pages</option>
<option value="all">Split All Pages into Separate Files</option>
<option value="visual">Select Pages Visually</option>
<option value="bookmarks">Split by Bookmarks</option>
<option value="n-times">Split N Times</option>
</select>
<div id="range-panel">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>Enter page numbers separated by commas (e.g., 2, 8, 14).</li>
<li>Enter page ranges using a hyphen (e.g., 5-10).</li>
<li>Combine them for complex selections (e.g., 1-3, 7, 12-15).</li>
</ul>
</div>
<label for="page-range" class="block mb-2 text-sm font-medium text-gray-300">Page Range</label>
<input type="text" id="page-range"
class="bg-gray-700 border border-gray-600 text-white text-sm rounded-lg focus:ring-indigo-500 focus:border-indigo-500 block w-full p-2.5"
placeholder="e.g. 1-5, 8, 11-13">
</div>
<div id="even-odd-panel" class="hidden">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>Extract all even pages (2, 4, 6...) or all odd pages (1, 3, 5...) into a new PDF.</li>
</ul>
</div>
<div class="flex items-center mb-4">
<input id="split-even" type="radio" value="even" name="even-odd-choice"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2">
<label for="split-even" class="ml-2 text-sm font-medium text-gray-300">Even Pages</label>
</div>
<div class="flex items-center">
<input id="split-odd" type="radio" value="odd" name="even-odd-choice"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2">
<label for="split-odd" class="ml-2 text-sm font-medium text-gray-300">Odd Pages</label>
</div>
</div>
<div id="all-pages-panel" class="hidden">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>Every single page of the PDF will be saved as a separate PDF file.</li>
<li>The result will be downloaded as a ZIP file containing all the pages.</li>
</ul>
</div>
</div>
<div id="visual-select-panel" class="hidden">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>Click on the page thumbnails below to select the pages you want to extract.</li>
<li>Selected pages will be highlighted.</li>
</ul>
</div>
<div id="page-selector-grid"
class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-4 max-h-96 overflow-y-auto p-2 border border-gray-700 rounded-lg">
<!-- Thumbnails will be rendered here -->
</div>
</div>
<div id="bookmarks-panel" class="hidden">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>Split the PDF based on its bookmarks (outline).</li>
<li>Select the bookmark level to split at.</li>
</ul>
</div>
<label for="bookmark-level" class="block mb-2 text-sm font-medium text-gray-300">Bookmark
Level</label>
<select id="bookmark-level"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 mb-4">
<option value="all" selected>All Levels</option>
<option value="0">Level 0 (Top Level Only)</option>
<option value="1">Level 1</option>
<option value="2">Level 2</option>
<option value="3">Level 3</option>
</select>
</div>
<div id="n-times-panel" class="hidden">
<div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-3">
<p class="text-sm text-gray-300"><strong class="text-white">How it works:</strong></p>
<ul class="list-disc list-inside text-xs text-gray-400 mt-1 space-y-1">
<li>Split the PDF into multiple files, each containing N pages.</li>
</ul>
</div>
<label for="split-n-value" class="block mb-2 text-sm font-medium text-gray-300">Pages per file
(N)</label>
<input type="number" id="split-n-value" min="1" value="5"
class="bg-gray-700 border border-gray-600 text-white text-sm rounded-lg focus:ring-indigo-500 focus:border-indigo-500 block w-full p-2.5">
<p id="n-times-warning" class="mt-2 text-sm text-yellow-400 hidden">
<i data-lucide="alert-triangle" class="inline-block w-4 h-4 mr-1"></i>
<span id="n-times-warning-text"></span>
</p>
</div>
<div id="zip-option-wrapper" class="mt-4 flex items-center">
<input id="download-as-zip" type="checkbox"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2">
<label for="download-as-zip" class="ml-2 text-sm font-medium text-gray-300">Download as ZIP (for
multiple files)</label>
</div>
<button id="process-btn" class="btn-gradient w-full mt-6">Split PDF</button>
</div>
</div>
</div>
<!-- Loader Modal -->
<div id="loader-modal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl">
<div class="solid-spinner"></div>
<p id="loader-text" class="text-white text-lg font-medium">
Processing...
</p>
</div>
</div>
<!-- Alert Modal -->
<div id="alert-modal" class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden">
<div class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700">
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">Alert</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200">
OK
</button>
</div>
</div>
<footer class="mt-16 border-t-2 border-gray-700 py-8">
<div class="container mx-auto px-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
&copy; 2025 BentoPDF. All rights reserved.
</p>
<p class="text-gray-500 text-xs mt-2">
Version <span id="app-version"></span>
</p>
</div>
<div>
<h3 class="font-bold text-white mb-4">Company</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../about.html" class="hover:text-indigo-400">About Us</a>
</li>
<li>
<a href="../../faq.html" class="hover:text-indigo-400">FAQ</a>
</li>
<li>
<a href="../../contact.html" class="hover:text-indigo-400">Contact Us</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Legal</h3>
<ul class="space-y-2 text-gray-400">
<li>
<a href="../../licensing.html" class="hover:text-indigo-400">Licensing</a>
</li>
<li>
<a href="../../terms.html" class="hover:text-indigo-400">Terms and Conditions</a>
</li>
<li>
<a href="../../privacy.html" class="hover:text-indigo-400">Privacy Policy</a>
</li>
</ul>
</div>
<div>
<h3 class="font-bold text-white mb-4">Follow Us</h3>
<div class="flex justify-center md:justify-start space-x-4">
<a href="https://github.com/alam00000/bentopdf" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd" />
</svg>
</a>
<a href="https://discord.gg/AP2Y97juZT" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-indigo-400" title="Discord">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
<a href="https://www.instagram.com/thebentopdf/" class="text-gray-400 hover:text-indigo-400"
title="Instagram">
<i data-lucide="instagram"></i>
</a>
<a href="https://www.linkedin.com/company/bentopdf/" class="text-gray-400 hover:text-indigo-400"
title="LinkedIn">
<i data-lucide="linkedin"></i>
</a>
<a href="https://x.com/BentoPDF" class="text-gray-400 hover:text-indigo-400"
title="X (Twitter)">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>
<script type="module" src="../js/utils/lucide-init.ts"></script>
<script type="module" src="../js/utils/full-width.ts"></script>
<script type="module" src="../js/utils/simple-mode-footer.ts"></script>
<script type="module" src="../version.ts"></script>
<script type="module" src="../js/logic/split-pdf-page.ts"></script>
<script type="module" src="../js/mobileMenu.ts"></script>
</body>
</html>
+7 -7
View File
@@ -17,18 +17,18 @@
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer" id="home-logo">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-8 w-8" />
<span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a>
<a href="../../index.html">BentoPDF</a>
</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8 text-white">
<a href="/" class="nav-link">Home</a>
<a href="../../index.html" class="nav-link">Home</a>
<a href="/about.html" class="nav-link">About</a>
<a href="/contact.html" class="nav-link">Contact</a>
<a href="/" class="nav-link">All Tools</a>
<a href="../../index.html" class="nav-link">All Tools</a>
</div>
<!-- Mobile Hamburger Button -->
@@ -55,10 +55,10 @@
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden bg-gray-800 border-t border-gray-700">
<div class="px-2 pt-2 pb-3 space-y-1 text-center">
<a href="/" class="mobile-nav-link">Home</a>
<a href="../../index.html" class="mobile-nav-link">Home</a>
<a href="/about.html" class="mobile-nav-link">About</a>
<a href="/contact.html" class="mobile-nav-link">Contact</a>
<a href="/" class="mobile-nav-link">All Tools</a>
<a href="../../index.html" class="mobile-nav-link">All Tools</a>
</div>
</div>
</nav>
@@ -179,7 +179,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 text-center md:text-left">
<div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4">
<img src="../../public/images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<img src="../../images/favicon.svg" alt="Bento PDF Logo" class="h-10 w-10 mr-3" />
<span class="text-xl font-bold text-white">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
+7
View File
@@ -4,6 +4,7 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { resolve } from 'path';
export default defineConfig(({ mode }) => ({
base: process.env.BASE_URL || '/',
plugins: [
tailwindcss(),
nodePolyfills({
@@ -62,6 +63,12 @@ export default defineConfig(({ mode }) => ({
'add-stamps': resolve(__dirname, 'src/pages/add-stamps.html'),
'form-creator': resolve(__dirname, 'src/pages/form-creator.html'),
'repair-pdf': resolve(__dirname, 'src/pages/repair-pdf.html'),
'merge-pdf': resolve(__dirname, 'src/pages/merge-pdf.html'),
'split-pdf': resolve(__dirname, 'src/pages/split-pdf.html'),
'compress-pdf': resolve(__dirname, 'src/pages/compress-pdf.html'),
'edit-pdf': resolve(__dirname, 'src/pages/edit-pdf.html'),
'jpg-to-pdf': resolve(__dirname, 'src/pages/jpg-to-pdf.html'),
},
},
},