Files
bentopdf/src/js/logic/pdf-booklet-page.ts
alam00000 9d0b68e18c Refactor and enhance type safety across various modules
- Updated function parameters and return types in `page-preview.ts`, `pdf-decrypt.ts`, and `pymupdf-loader.ts` for improved type safety.
- Introduced type definitions for `CpdfInstance`, `PyMuPDFInstance`, and other related types to ensure better type checking.
- Enhanced error handling in `sanitize.ts` by creating a utility function for error messages.
- Removed unnecessary type assertions and improved type inference in `editor.ts`, `serialization.ts`, and `tools.test.ts`.
- Added type definitions for markdown-it plugins to improve compatibility and type safety.
- Enforced stricter TypeScript settings by enabling `noImplicitAny` in `tsconfig.json`.
- Cleaned up test files by refining type assertions and ensuring consistency in type usage.
2026-03-31 17:59:49 +05:30

624 lines
19 KiB
TypeScript

import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
interface BookletState {
file: File | null;
pdfDoc: PDFLibDocument | null;
pdfBytes: Uint8Array | null;
pdfjsDoc: pdfjsLib.PDFDocumentProxy | null;
}
const pageState: BookletState = {
file: null,
pdfDoc: null,
pdfBytes: null,
pdfjsDoc: null,
};
function resetState() {
pageState.file = null;
pageState.pdfDoc = null;
pageState.pdfBytes = null;
pageState.pdfjsDoc = null;
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const toolOptions = document.getElementById('tool-options');
if (toolOptions) toolOptions.classList.add('hidden');
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
const previewArea = document.getElementById('booklet-preview');
if (previewArea)
previewArea.innerHTML =
'<p class="text-gray-400 text-center py-8">Upload a PDF and click "Generate Preview" to see the booklet layout</p>';
const downloadBtn = document.getElementById(
'download-btn'
) as HTMLButtonElement;
if (downloadBtn) downloadBtn.disabled = true;
}
async function updateUI() {
const fileDisplayArea = document.getElementById('file-display-area');
const toolOptions = document.getElementById('tool-options');
if (!fileDisplayArea) return;
fileDisplayArea.innerHTML = '';
if (pageState.file) {
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 nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
infoContainer.append(nameSpan, metaSpan);
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 = function () {
resetState();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
try {
const result = await loadPdfWithPasswordPrompt(pageState.file);
if (!result) {
resetState();
return;
}
showLoader('Loading PDF...');
pageState.file = result.file;
pageState.pdfBytes = new Uint8Array(result.bytes);
pageState.pdfjsDoc = result.pdf;
pageState.pdfDoc = await loadPdfDocument(pageState.pdfBytes);
hideLoader();
const pageCount = pageState.pdfDoc.getPageCount();
metaSpan.textContent = `${formatBytes(pageState.file.size)}${pageCount} pages`;
if (toolOptions) toolOptions.classList.remove('hidden');
const previewBtn = document.getElementById(
'preview-btn'
) as HTMLButtonElement;
if (previewBtn) previewBtn.disabled = false;
} catch (error) {
console.error('Error loading PDF:', error);
hideLoader();
showAlert('Error', 'Failed to load PDF file.');
resetState();
}
} else {
if (toolOptions) toolOptions.classList.add('hidden');
}
}
function getGridDimensions(): { rows: number; cols: number } {
const gridMode =
(
document.querySelector(
'input[name="grid-mode"]:checked'
) as HTMLInputElement
)?.value || '1x2';
switch (gridMode) {
case '1x2':
return { rows: 1, cols: 2 };
case '2x2':
return { rows: 2, cols: 2 };
case '2x4':
return { rows: 2, cols: 4 };
case '4x4':
return { rows: 4, cols: 4 };
default:
return { rows: 1, cols: 2 };
}
}
function getOrientation(isBookletMode: boolean): 'portrait' | 'landscape' {
const orientationValue =
(
document.querySelector(
'input[name="orientation"]:checked'
) as HTMLInputElement
)?.value || 'auto';
if (orientationValue === 'portrait') return 'portrait';
if (orientationValue === 'landscape') return 'landscape';
return isBookletMode ? 'landscape' : 'portrait';
}
function getSheetDimensions(isBookletMode: boolean): {
width: number;
height: number;
} {
const paperSizeKey = (
document.getElementById('paper-size') as HTMLSelectElement
).value as keyof typeof PageSizes;
const pageDims = PageSizes[paperSizeKey] || PageSizes.Letter;
const orientation = getOrientation(isBookletMode);
if (orientation === 'landscape') {
return { width: pageDims[1], height: pageDims[0] };
}
return { width: pageDims[0], height: pageDims[1] };
}
async function generatePreview() {
if (!pageState.pdfDoc || !pageState.pdfjsDoc) {
showAlert('Error', 'Please load a PDF first.');
return;
}
const previewArea = document.getElementById('booklet-preview')!;
const totalPages = pageState.pdfDoc.getPageCount();
const { rows, cols } = getGridDimensions();
const pagesPerSheet = rows * cols;
const isBookletMode = rows === 1 && cols === 2;
let numSheets: number;
if (isBookletMode) {
const sheetsNeeded = Math.ceil(totalPages / 4);
numSheets = sheetsNeeded * 2;
} else {
numSheets = Math.ceil(totalPages / pagesPerSheet);
}
const { width: sheetWidth, height: sheetHeight } =
getSheetDimensions(isBookletMode);
// Get container width to make canvas fill it
const previewContainer = document.getElementById('booklet-preview')!;
const containerWidth = previewContainer.clientWidth - 32; // account for padding
const aspectRatio = sheetWidth / sheetHeight;
const canvasWidth = containerWidth;
const canvasHeight = containerWidth / aspectRatio;
previewArea.innerHTML =
'<p class="text-gray-400 text-center py-4">Generating preview...</p>';
const totalRounded = isBookletMode
? Math.ceil(totalPages / 4) * 4
: totalPages;
const rotationMode =
(
document.querySelector(
'input[name="rotation"]:checked'
) as HTMLInputElement
)?.value || 'none';
const pageThumbnails: Map<number, ImageBitmap> = new Map();
const thumbnailScale = 1;
for (let i = 1; i <= totalPages; i++) {
try {
const page = await pageState.pdfjsDoc.getPage(i);
const viewport = page.getViewport({ scale: thumbnailScale });
const offscreen = new OffscreenCanvas(viewport.width, viewport.height);
const ctx = offscreen.getContext('2d')!;
await page.render({
canvasContext: ctx as unknown as CanvasRenderingContext2D,
viewport: viewport,
canvas: offscreen as unknown as HTMLCanvasElement,
}).promise;
const bitmap = await createImageBitmap(offscreen);
pageThumbnails.set(i, bitmap);
} catch (e) {
console.error(`Failed to render page ${i}:`, e);
}
}
previewArea.innerHTML = `<p class="text-indigo-400 text-sm mb-4 text-center">${totalPages} pages → ${numSheets} output sheets</p>`;
for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) {
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
canvas.className = 'border border-gray-600 rounded-lg mb-4';
const ctx = canvas.getContext('2d')!;
const isFront = sheetIndex % 2 === 0;
ctx.fillStyle = isFront ? '#1f2937' : '#1a2e1a';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
ctx.strokeStyle = '#4b5563';
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, canvasWidth, canvasHeight);
const cellWidth = canvasWidth / cols;
const cellHeight = canvasHeight / rows;
const padding = 4;
ctx.strokeStyle = '#374151';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
for (let c = 1; c < cols; c++) {
ctx.beginPath();
ctx.moveTo(c * cellWidth, 0);
ctx.lineTo(c * cellWidth, canvasHeight);
ctx.stroke();
}
for (let r = 1; r < rows; r++) {
ctx.beginPath();
ctx.moveTo(0, r * cellHeight);
ctx.lineTo(canvasWidth, r * cellHeight);
ctx.stroke();
}
ctx.setLineDash([]);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const slotIndex = r * cols + c;
let pageNumber: number;
if (isBookletMode) {
const physicalSheet = Math.floor(sheetIndex / 2);
const isFrontSide = sheetIndex % 2 === 0;
if (isFrontSide) {
pageNumber =
c === 0
? totalRounded - 2 * physicalSheet
: 2 * physicalSheet + 1;
} else {
pageNumber =
c === 0
? 2 * physicalSheet + 2
: totalRounded - 2 * physicalSheet - 1;
}
} else {
pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1;
}
const x = c * cellWidth + padding;
const y = r * cellHeight + padding;
const slotWidth = cellWidth - padding * 2;
const slotHeight = cellHeight - padding * 2;
const exists = pageNumber >= 1 && pageNumber <= totalPages;
if (exists) {
const thumbnail = pageThumbnails.get(pageNumber);
if (thumbnail) {
let rotation = 0;
if (rotationMode === '90cw') rotation = 90;
else if (rotationMode === '90ccw') rotation = -90;
else if (rotationMode === 'alternate')
rotation = pageNumber % 2 === 1 ? 90 : -90;
const isRotated = rotation !== 0;
const srcWidth = isRotated ? thumbnail.height : thumbnail.width;
const srcHeight = isRotated ? thumbnail.width : thumbnail.height;
const scale = Math.min(
slotWidth / srcWidth,
slotHeight / srcHeight
);
const drawWidth = srcWidth * scale;
const drawHeight = srcHeight * scale;
const drawX = x + (slotWidth - drawWidth) / 2;
const drawY = y + (slotHeight - drawHeight) / 2;
ctx.save();
if (rotation !== 0) {
const centerX = drawX + drawWidth / 2;
const centerY = drawY + drawHeight / 2;
ctx.translate(centerX, centerY);
ctx.rotate((rotation * Math.PI) / 180);
ctx.drawImage(
thumbnail,
-drawHeight / 2,
-drawWidth / 2,
drawHeight,
drawWidth
);
} else {
ctx.drawImage(thumbnail, drawX, drawY, drawWidth, drawHeight);
}
ctx.restore();
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.strokeRect(drawX, drawY, drawWidth, drawHeight);
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(
`${pageNumber}`,
x + slotWidth / 2,
y + slotHeight - 4
);
}
} else {
ctx.fillStyle = '#374151';
ctx.fillRect(x, y, slotWidth, slotHeight);
ctx.strokeStyle = '#4b5563';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, slotWidth, slotHeight);
ctx.fillStyle = '#6b7280';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('(blank)', x + slotWidth / 2, y + slotHeight / 2);
}
}
}
ctx.fillStyle = '#9ca3af';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
const sideLabel = isBookletMode ? (isFront ? 'Front' : 'Back') : '';
ctx.fillText(
`Sheet ${Math.floor(sheetIndex / (isBookletMode ? 2 : 1)) + 1} ${sideLabel}`,
canvasWidth - 6,
4
);
previewArea.appendChild(canvas);
}
pageThumbnails.forEach((bitmap) => bitmap.close());
const downloadBtn = document.getElementById(
'download-btn'
) as HTMLButtonElement;
downloadBtn.disabled = false;
}
function applyRotation(doc: PDFLibDocument, mode: string) {
const pages = doc.getPages();
pages.forEach((page, index) => {
let rotation: number;
switch (mode) {
case '90cw':
rotation = 90;
break;
case '90ccw':
rotation = -90;
break;
case 'alternate':
rotation = index % 2 === 0 ? 90 : -90;
break;
default:
rotation = 0;
}
if (rotation !== 0) {
page.setRotation(degrees(page.getRotation().angle + rotation));
}
});
}
async function createBooklet() {
if (!pageState.pdfBytes) {
showAlert('Error', 'Please load a PDF first.');
return;
}
showLoader('Creating Booklet...');
try {
const sourceDoc = await loadPdfDocument(pageState.pdfBytes.slice());
const rotationMode =
(
document.querySelector(
'input[name="rotation"]:checked'
) as HTMLInputElement
)?.value || 'none';
applyRotation(sourceDoc, rotationMode);
const totalPages = sourceDoc.getPageCount();
const { rows, cols } = getGridDimensions();
const pagesPerSheet = rows * cols;
const isBookletMode = rows === 1 && cols === 2;
const { width: sheetWidth, height: sheetHeight } =
getSheetDimensions(isBookletMode);
const outputDoc = await PDFLibDocument.create();
let numSheets: number;
let totalRounded: number;
if (isBookletMode) {
totalRounded = Math.ceil(totalPages / 4) * 4;
numSheets = Math.ceil(totalPages / 4) * 2;
} else {
totalRounded = totalPages;
numSheets = Math.ceil(totalPages / pagesPerSheet);
}
const cellWidth = sheetWidth / cols;
const cellHeight = sheetHeight / rows;
const padding = 10;
for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) {
const outputPage = outputDoc.addPage([sheetWidth, sheetHeight]);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const slotIndex = r * cols + c;
let pageNumber: number;
if (isBookletMode) {
const physicalSheet = Math.floor(sheetIndex / 2);
const isFrontSide = sheetIndex % 2 === 0;
if (isFrontSide) {
pageNumber =
c === 0
? totalRounded - 2 * physicalSheet
: 2 * physicalSheet + 1;
} else {
pageNumber =
c === 0
? 2 * physicalSheet + 2
: totalRounded - 2 * physicalSheet - 1;
}
} else {
pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1;
}
if (pageNumber >= 1 && pageNumber <= totalPages) {
const [embeddedPage] = await outputDoc.embedPdf(sourceDoc, [
pageNumber - 1,
]);
const { width: srcW, height: srcH } = embeddedPage;
const availableWidth = cellWidth - padding * 2;
const availableHeight = cellHeight - padding * 2;
const scale = Math.min(
availableWidth / srcW,
availableHeight / srcH
);
const scaledWidth = srcW * scale;
const scaledHeight = srcH * scale;
const x =
c * cellWidth + padding + (availableWidth - scaledWidth) / 2;
const y =
sheetHeight -
(r + 1) * cellHeight +
padding +
(availableHeight - scaledHeight) / 2;
outputPage.drawPage(embeddedPage, {
x,
y,
width: scaledWidth,
height: scaledHeight,
});
}
}
}
}
const pdfBytes = await outputDoc.save();
const originalName =
pageState.file?.name.replace(/\.pdf$/i, '') || 'document';
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
`${originalName}_booklet.pdf`
);
showAlert(
'Success',
`Booklet created with ${numSheets} sheets!`,
'success',
function () {
resetState();
}
);
} catch (e) {
console.error(e);
showAlert('Error', 'An error occurred while creating the booklet.');
} finally {
hideLoader();
}
}
function handleFileSelect(files: FileList | null) {
if (files && files.length > 0) {
const file = files[0];
if (
file.type === 'application/pdf' ||
file.name.toLowerCase().endsWith('.pdf')
) {
pageState.file = file;
updateUI();
}
}
}
document.addEventListener('DOMContentLoaded', function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const previewBtn = document.getElementById('preview-btn');
const downloadBtn = document.getElementById('download-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', function () {
window.location.href = import.meta.env.BASE_URL;
});
}
if (fileInput && dropZone) {
fileInput.addEventListener('change', function (e) {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', function (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(function (f) {
return (
f.type === 'application/pdf' ||
f.name.toLowerCase().endsWith('.pdf')
);
});
if (pdfFiles.length > 0) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(pdfFiles[0]);
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', function () {
fileInput.value = '';
});
}
if (previewBtn) {
previewBtn.addEventListener('click', generatePreview);
}
if (downloadBtn) {
downloadBtn.addEventListener('click', createBooklet);
}
});