Merge remote-tracking branch 'upstream/main' into add-spanish-translation

This commit is contained in:
Raul Gonzalez
2026-01-06 08:27:11 -06:00
262 changed files with 35303 additions and 27897 deletions

View File

@@ -720,6 +720,18 @@ export const categories = [
icon: 'ph-shield-check',
subtitle: 'Set or change user permissions on a PDF.',
},
{
href: import.meta.env.BASE_URL + 'digital-sign-pdf.html',
name: 'Digital Signature',
icon: 'ph-certificate',
subtitle: 'Add a cryptographic digital signature using X.509 certificates.',
},
{
href: import.meta.env.BASE_URL + 'validate-signature-pdf.html',
name: 'Validate Signature',
icon: 'ph-seal-check',
subtitle: 'Verify digital signatures and view certificate details.',
},
],
},
];

View File

@@ -9,12 +9,11 @@ const USE_CDN = import.meta.env.VITE_USE_CDN === 'true';
import { CDN_URLS, PACKAGE_VERSIONS } from '../const/cdn-version';
const LOCAL_PATHS = {
libreoffice: import.meta.env.BASE_URL + 'libreoffice-wasm/',
ghostscript: import.meta.env.BASE_URL + 'ghostscript-wasm/',
pymupdf: import.meta.env.BASE_URL + 'pymupdf-wasm/',
} as const;
export type WasmPackage = 'libreoffice' | 'ghostscript' | 'pymupdf';
export type WasmPackage = 'ghostscript' | 'pymupdf';
export function getWasmBaseUrl(packageName: WasmPackage): string {
if (USE_CDN) {

View File

@@ -1,11 +1,9 @@
export const PACKAGE_VERSIONS = {
libreoffice: '2.3.1',
ghostscript: '0.1.0',
pymupdf: '0.1.9',
ghostscript: '0.1.0',
pymupdf: '0.1.9',
} as const;
export const CDN_URLS = {
libreoffice: `https://cdn.jsdelivr.net/npm/@bentopdf/libreoffice-wasm@${PACKAGE_VERSIONS.libreoffice}/assets/`,
ghostscript: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@${PACKAGE_VERSIONS.ghostscript}/assets/`,
pymupdf: `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@${PACKAGE_VERSIONS.pymupdf}/assets/`,
} as const;

View File

@@ -170,14 +170,14 @@ async function handleSinglePdfUpload(toolId, file) {
if (rotateAllDecrementBtn) {
rotateAllDecrementBtn.onclick = () => {
let current = parseInt(rotateAllCustomInput.value) || 0;
const current = parseInt(rotateAllCustomInput.value) || 0;
rotateAllCustomInput.value = (current - 1).toString();
};
}
if (rotateAllIncrementBtn) {
rotateAllIncrementBtn.onclick = () => {
let current = parseInt(rotateAllCustomInput.value) || 0;
const current = parseInt(rotateAllCustomInput.value) || 0;
rotateAllCustomInput.value = (current + 1).toString();
};
}
@@ -262,7 +262,7 @@ async function handleSinglePdfUpload(toolId, file) {
const infoSection = createSection('Info Dictionary');
if (info && Object.keys(info).length > 0) {
for (const key in info) {
let value = info[key];
const value = info[key];
let displayValue;
if (value === null || typeof value === 'undefined') {

View File

@@ -3,7 +3,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';
// Supported languages
export const supportedLanguages = ['en', 'de', 'es', 'zh', 'vi'] as const;
export const supportedLanguages = ['en', 'de', 'es', 'zh', 'vi', 'it'] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const languageNames: Record<SupportedLanguage, string> = {
@@ -12,11 +12,12 @@ export const languageNames: Record<SupportedLanguage, string> = {
es: 'Español',
zh: '中文',
vi: 'Tiếng Việt',
it: 'Italiano',
};
export const getLanguageFromUrl = (): SupportedLanguage => {
const path = window.location.pathname;
const langMatch = path.match(/^\/(en|de|es|zh|vi)(?:\/|$)/);
const langMatch = path.match(/^\/(en|de|es|zh|vi|it)(?:\/|$)/);
if (langMatch && supportedLanguages.includes(langMatch[1] as SupportedLanguage)) {
return langMatch[1] as SupportedLanguage;
}
@@ -72,12 +73,12 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
const currentLang = getLanguageFromUrl();
let newPath: string;
if (currentPath.match(/^\/(en|de|zh|vi)\//)) {
newPath = currentPath.replace(/^\/(en|de|zh|vi)\//, `/${lang}/`);
} else if (currentPath.match(/^\/(en|de|zh|vi)$/)) {
newPath = `/${lang}`;
if (currentPath.match(/^\/(en|de|zh|vi|it)\//)) {
newPath = currentPath.replace(/^\/(en|de|zh|vi|it)\//, `/${lang}/`);
} else if (currentPath.match(/^\/(en|de|zh|vi|it)$/)) {
newPath = `/${lang}`;
} else {
newPath = `/${lang}${currentPath}`;
newPath = `/${lang}${currentPath}`;
}
const newUrl = newPath + window.location.search + window.location.hash;
@@ -136,8 +137,8 @@ export const rewriteLinks = (): void => {
return;
}
if (href.match(/^\/(en|de|zh|vi)\//)) {
return;
if (href.match(/^\/(en|de|zh|vi|it)\//)) {
return;
}
let newHref: string;
if (href.startsWith('/')) {

View File

@@ -1,3 +1,4 @@
import { AddAttachmentState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
@@ -5,12 +6,6 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib';
const worker = new Worker(import.meta.env.BASE_URL + 'workers/add-attachments.worker.js');
interface AddAttachmentState {
file: File | null;
pdfDoc: PDFLibDocument | null;
attachments: File[];
}
const pageState: AddAttachmentState = {
file: null,
pdfDoc: null,

View File

@@ -2,11 +2,8 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { AddBlankPageState } from '@/types';
interface AddBlankPageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: AddBlankPageState = {
file: null,

View File

@@ -35,8 +35,7 @@ function resetState() {
viewerContainer.style.aspectRatio = ''
}
// Revert container width only if NOT in full width mode
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true'
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false'
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-6xl')
toolUploader.classList.add('max-w-2xl')
@@ -56,8 +55,8 @@ function updateFileList() {
fileListDiv.classList.remove('hidden')
fileListDiv.innerHTML = ''
// Expand container width for viewer if NOT in full width mode
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true'
// Expand container width for viewer if NOT in full width mode (default to true if not set)
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false'
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-2xl')
toolUploader.classList.add('max-w-6xl')

View File

@@ -2,13 +2,9 @@ import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes, readFileAsArrayBuffer } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb, degrees, StandardFonts } from 'pdf-lib';
import { AddWatermarkState } from '@/types';
interface PageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: AddWatermarkState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -1,14 +1,9 @@
import { AlternateMergeState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import Sortable from 'sortablejs';
interface AlternateMergeState {
files: File[];
pdfBytes: Map<string, ArrayBuffer>;
pdfDocs: Map<string, any>;
}
const pageState: AlternateMergeState = {
files: [],
pdfBytes: new Map(),

View File

@@ -2,9 +2,9 @@ import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb } from 'pdf-lib';
import { BackgroundColorState } from '@/types';
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: BackgroundColorState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); }
else { initializePage(); }

View File

@@ -771,8 +771,8 @@ let searchQuery = '';
let csvBookmarks = null;
let jsonBookmarks = null;
let batchMode = false;
let selectedBookmarks = new Set();
let collapsedNodes = new Set();
const selectedBookmarks = new Set();
const collapsedNodes = new Set();
const colorClasses = {
red: 'bg-red-100 border-red-300',
@@ -1126,7 +1126,7 @@ async function renderPage(num, zoom = null, destX = null, destY = null) {
const dpr = window.devicePixelRatio || 1;
let viewport = page.getViewport({ scale: zoomScale });
const viewport = page.getViewport({ scale: zoomScale });
currentViewport = viewport;
canvas.height = viewport.height * dpr;

View File

@@ -4,10 +4,139 @@ import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
import JSZip from 'jszip';
import { PDFDocument } from 'pdf-lib';
const FILETYPE = 'cbz';
const EXTENSIONS = ['.cbz', '.cbr'];
const TOOL_NAME = 'CBZ';
const ALL_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.avif', '.jxl', '.heic', '.heif'];
const IMAGE_SIGNATURES = {
jpeg: [0xFF, 0xD8, 0xFF],
png: [0x89, 0x50, 0x4E, 0x47],
gif: [0x47, 0x49, 0x46],
bmp: [0x42, 0x4D],
webp: [0x52, 0x49, 0x46, 0x46],
avif: [0x00, 0x00, 0x00],
};
function matchesSignature(data: Uint8Array, signature: number[], offset = 0): boolean {
for (let i = 0; i < signature.length; i++) {
if (data[offset + i] !== signature[i]) return false;
}
return true;
}
function detectImageFormat(data: Uint8Array): 'jpeg' | 'png' | 'gif' | 'bmp' | 'webp' | 'avif' | 'unknown' {
if (data.length < 12) return 'unknown';
if (matchesSignature(data, IMAGE_SIGNATURES.jpeg)) return 'jpeg';
if (matchesSignature(data, IMAGE_SIGNATURES.png)) return 'png';
if (matchesSignature(data, IMAGE_SIGNATURES.gif)) return 'gif';
if (matchesSignature(data, IMAGE_SIGNATURES.bmp)) return 'bmp';
if (matchesSignature(data, IMAGE_SIGNATURES.webp) &&
data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
return 'webp';
}
if (data[4] === 0x66 && data[5] === 0x74 && data[6] === 0x79 && data[7] === 0x70) {
const brand = String.fromCharCode(data[8], data[9], data[10], data[11]);
if (brand === 'avif' || brand === 'avis' || brand === 'mif1' || brand === 'miaf') {
return 'avif';
}
}
return 'unknown';
}
function isCbzFile(filename: string): boolean {
return filename.toLowerCase().endsWith('.cbz');
}
async function convertImageToPng(imageData: ArrayBuffer, filename: string): Promise<Blob> {
return new Promise((resolve, reject) => {
const blob = new Blob([imageData]);
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url);
if (pngBlob) {
resolve(pngBlob);
} else {
reject(new Error(`Failed to convert ${filename} to PNG`));
}
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error(`Failed to load image: ${filename}`));
};
img.src = url;
});
}
async function convertCbzToPdf(file: File): Promise<Blob> {
const zip = await JSZip.loadAsync(file);
const pdfDoc = await PDFDocument.create();
const imageFiles = Object.keys(zip.files)
.filter(name => {
if (zip.files[name].dir) return false;
const ext = name.toLowerCase().substring(name.lastIndexOf('.'));
return ALL_IMAGE_EXTENSIONS.includes(ext);
})
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
for (const filename of imageFiles) {
const zipEntry = zip.files[filename];
const imageData = await zipEntry.async('arraybuffer');
const dataArray = new Uint8Array(imageData);
const actualFormat = detectImageFormat(dataArray);
let imageBytes: Uint8Array;
let embedMethod: 'png' | 'jpg';
if (actualFormat === 'jpeg') {
imageBytes = dataArray;
embedMethod = 'jpg';
} else if (actualFormat === 'png') {
imageBytes = dataArray;
embedMethod = 'png';
} else {
const pngBlob = await convertImageToPng(imageData, filename);
imageBytes = new Uint8Array(await pngBlob.arrayBuffer());
embedMethod = 'png';
}
const image = embedMethod === 'png'
? await pdfDoc.embedPng(imageBytes)
: await pdfDoc.embedJpg(imageBytes);
const page = pdfDoc.addPage([image.width, image.height]);
page.drawImage(image, {
x: 0,
y: 0,
width: image.width,
height: image.height,
});
}
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' });
}
async function convertCbrToPdf(file: File): Promise<Blob> {
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
return await pymupdf.convertToPdf(file, { filetype: 'cbz' });
}
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
@@ -86,17 +215,18 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
showLoader('Loading engine...');
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Converting ${originalFile.name}...`);
const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE });
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
let pdfBlob: Blob;
if (isCbzFile(originalFile.name)) {
pdfBlob = await convertCbzToPdf(originalFile);
} else {
pdfBlob = await convertCbrToPdf(originalFile);
}
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
@@ -108,21 +238,26 @@ document.addEventListener('DOMContentLoaded', () => {
);
} else {
showLoader('Converting files...');
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
const outputZip = new JSZip();
for (let i = 0; i < state.files.length; i++) {
const file = state.files[i];
showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE });
let pdfBlob: Blob;
if (isCbzFile(file.name)) {
pdfBlob = await convertCbzToPdf(file);
} else {
pdfBlob = await convertCbrToPdf(file);
}
const baseName = file.name.replace(/\.[^.]+$/, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
outputZip.file(`${baseName}.pdf`, pdfBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
const zipBlob = await outputZip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'comic-converted.zip');
hideLoader();

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { ChangePermissionsState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: ChangePermissionsState = {
file: null,
};

View File

@@ -3,15 +3,11 @@ import { downloadFile, formatBytes, hexToRgb, getPDFDocument } from '../utils/he
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument, rgb } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { CombineSinglePageState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface CombineState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: CombineState = {
const pageState: CombineSinglePageState = {
file: null,
pdfDoc: null,
};

View File

@@ -2,17 +2,10 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { getPDFDocument } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import { CompareState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface CompareState {
pdfDoc1: pdfjsLib.PDFDocumentProxy | null;
pdfDoc2: pdfjsLib.PDFDocumentProxy | null;
currentPage: number;
viewMode: 'overlay' | 'side-by-side';
isSyncScroll: boolean;
}
const pageState: CompareState = {
pdfDoc1: null,
pdfDoc2: null,

View File

@@ -4,18 +4,10 @@ import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from
import Cropper from 'cropperjs';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { CropperState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface CropperState {
pdfDoc: any;
currentPageNum: number;
cropper: any;
originalPdfBytes: ArrayBuffer | null;
pageCrops: Record<number, any>;
file: File | null;
}
const cropperState: CropperState = {
pdfDoc: null,
currentPageNum: 1,

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { DecryptPdfState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: DecryptPdfState = {
file: null,
};

View File

@@ -3,18 +3,11 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument, parsePageRanges } from '../utils/helpers.js';
import { PDFDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { DeletePagesState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface DeleteState {
file: File | null;
pdfDoc: any;
pdfJsDoc: any;
totalPages: number;
pagesToDelete: Set<number>;
}
const deleteState: DeleteState = {
const deleteState: DeletePagesState = {
file: null,
pdfDoc: null,
pdfJsDoc: null,

View File

@@ -0,0 +1,689 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument } from '../utils/helpers.js';
import {
signPdf,
parsePfxFile,
parseCombinedPem,
getCertificateInfo,
} from './digital-sign-pdf.js';
import { SignatureInfo, VisibleSignatureOptions, DigitalSignState } from '@/types';
const state: DigitalSignState = {
pdfFile: null,
pdfBytes: null,
certFile: null,
certData: null,
sigImageData: null,
sigImageType: null,
};
function resetState(): void {
state.pdfFile = null;
state.pdfBytes = null;
state.certFile = null;
state.certData = null;
state.sigImageData = null;
state.sigImageType = null;
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
const sigImageInput = getElement<HTMLInputElement>('sig-image-input');
if (sigImageInput) sigImageInput.value = '';
const sigImagePreview = getElement<HTMLDivElement>('sig-image-preview');
if (sigImagePreview) sigImagePreview.classList.add('hidden');
const certSection = getElement<HTMLDivElement>('certificate-section');
if (certSection) certSection.classList.add('hidden');
hidePasswordSection();
hideSignatureOptions();
hideCertInfo();
updateProcessButton();
}
function getElement<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
function initializePage(): void {
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
const certPassword = getElement<HTMLInputElement>('cert-password');
const processBtn = getElement<HTMLButtonElement>('process-btn');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
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) {
handlePdfFile(droppedFiles[0]);
}
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
if (certPassword) {
certPassword.addEventListener('input', handlePasswordInput);
certPassword.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handlePasswordInput();
}
});
}
if (processBtn) {
processBtn.addEventListener('click', processSignature);
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const enableVisibleSig = getElement<HTMLInputElement>('enable-visible-sig');
const visibleSigOptions = getElement<HTMLDivElement>('visible-sig-options');
const sigPage = getElement<HTMLSelectElement>('sig-page');
const customPageWrapper = getElement<HTMLDivElement>('custom-page-wrapper');
const sigImageInput = getElement<HTMLInputElement>('sig-image-input');
const sigImagePreview = getElement<HTMLDivElement>('sig-image-preview');
const sigImageThumb = getElement<HTMLImageElement>('sig-image-thumb');
const removeSigImage = getElement<HTMLButtonElement>('remove-sig-image');
const enableSigText = getElement<HTMLInputElement>('enable-sig-text');
const sigTextOptions = getElement<HTMLDivElement>('sig-text-options');
if (enableVisibleSig && visibleSigOptions) {
enableVisibleSig.addEventListener('change', () => {
if (enableVisibleSig.checked) {
visibleSigOptions.classList.remove('hidden');
} else {
visibleSigOptions.classList.add('hidden');
}
});
}
if (sigPage && customPageWrapper) {
sigPage.addEventListener('change', () => {
if (sigPage.value === 'custom') {
customPageWrapper.classList.remove('hidden');
} else {
customPageWrapper.classList.add('hidden');
}
});
}
if (sigImageInput) {
sigImageInput.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
const file = input.files[0];
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
if (!validTypes.includes(file.type)) {
showAlert('Invalid Image', 'Please select a PNG, JPG, or WebP image.');
return;
}
state.sigImageData = await readFileAsArrayBuffer(file) as ArrayBuffer;
state.sigImageType = file.type.replace('image/', '') as 'png' | 'jpeg' | 'webp';
if (sigImageThumb && sigImagePreview) {
const url = URL.createObjectURL(file);
sigImageThumb.src = url;
sigImagePreview.classList.remove('hidden');
}
}
});
}
if (removeSigImage && sigImagePreview) {
removeSigImage.addEventListener('click', () => {
state.sigImageData = null;
state.sigImageType = null;
sigImagePreview.classList.add('hidden');
if (sigImageInput) sigImageInput.value = '';
});
}
if (enableSigText && sigTextOptions) {
enableSigText.addEventListener('change', () => {
if (enableSigText.checked) {
sigTextOptions.classList.remove('hidden');
} else {
sigTextOptions.classList.add('hidden');
}
});
}
}
function handlePdfUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
}
async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
updatePdfDisplay();
showCertificateSection();
}
async function updatePdfDisplay(): Promise<void> {
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(state.pdfFile.size)} • Loading pages...`;
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 = () => {
state.pdfFile = null;
state.pdfBytes = null;
fileDisplayArea.innerHTML = '';
hideCertificateSection();
updateProcessButton();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
try {
if (state.pdfBytes) {
const pdfDoc = await getPDFDocument({ data: state.pdfBytes.slice() }).promise;
metaSpan.textContent = `${formatBytes(state.pdfFile.size)}${pdfDoc.numPages} pages`;
}
} catch (error) {
console.error('Error loading PDF:', error);
metaSpan.textContent = `${formatBytes(state.pdfFile.size)}`;
}
}
function showCertificateSection(): void {
const certSection = getElement<HTMLDivElement>('certificate-section');
if (certSection) {
certSection.classList.remove('hidden');
}
}
function hideCertificateSection(): void {
const certSection = getElement<HTMLDivElement>('certificate-section');
const signatureOptions = getElement<HTMLDivElement>('signature-options');
if (certSection) {
certSection.classList.add('hidden');
}
if (signatureOptions) {
signatureOptions.classList.add('hidden');
}
state.certFile = null;
state.certData = null;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) {
certDisplayArea.innerHTML = '';
}
const certInfo = getElement<HTMLDivElement>('cert-info');
if (certInfo) {
certInfo.classList.add('hidden');
}
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
if (certPasswordSection) {
certPasswordSection.classList.add('hidden');
}
}
function handleCertUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
}
async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pfx', '.p12', '.pem'];
const hasValidExtension = validExtensions.some(ext =>
file.name.toLowerCase().endsWith(ext)
);
if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pfx, .p12, or .pem certificate file.');
return;
}
state.certFile = file;
state.certData = null;
updateCertDisplay();
const isPemFile = file.name.toLowerCase().endsWith('.pem');
if (isPemFile) {
try {
const pemContent = await file.text();
const isEncrypted = pemContent.includes('ENCRYPTED');
if (isEncrypted) {
showPasswordSection();
updatePasswordLabel('Private Key Password');
} else {
state.certData = parseCombinedPem(pemContent);
updateCertInfo();
showSignatureOptions();
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = 'Certificate loaded <i data-lucide="check" class="inline w-4 h-4"></i>';
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
}
} catch (error) {
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.textContent = 'Failed to parse PEM file';
certStatus.className = 'text-xs text-red-400';
}
}
} else {
showPasswordSection();
updatePasswordLabel('Certificate Password');
}
hideSignatureOptions();
updateProcessButton();
}
function updateCertDisplay(): void {
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.certFile) return;
certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.certFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.id = 'cert-status';
metaSpan.textContent = 'Enter password to unlock';
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 = () => {
state.certFile = null;
state.certData = null;
certDisplayArea.innerHTML = '';
hidePasswordSection();
hideCertInfo();
hideSignatureOptions();
updateProcessButton();
};
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
}
function showPasswordSection(): void {
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
if (certPasswordSection) {
certPasswordSection.classList.remove('hidden');
}
const certPassword = getElement<HTMLInputElement>('cert-password');
if (certPassword) {
certPassword.value = '';
certPassword.focus();
}
}
function updatePasswordLabel(labelText: string): void {
const label = document.querySelector('label[for="cert-password"]');
if (label) {
label.textContent = labelText;
}
}
function hidePasswordSection(): void {
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
if (certPasswordSection) {
certPasswordSection.classList.add('hidden');
}
}
function showSignatureOptions(): void {
const signatureOptions = getElement<HTMLDivElement>('signature-options');
if (signatureOptions) {
signatureOptions.classList.remove('hidden');
}
const visibleSigSection = getElement<HTMLDivElement>('visible-signature-section');
if (visibleSigSection) {
visibleSigSection.classList.remove('hidden');
}
}
function hideSignatureOptions(): void {
const signatureOptions = getElement<HTMLDivElement>('signature-options');
if (signatureOptions) {
signatureOptions.classList.add('hidden');
}
const visibleSigSection = getElement<HTMLDivElement>('visible-signature-section');
if (visibleSigSection) {
visibleSigSection.classList.add('hidden');
}
}
function hideCertInfo(): void {
const certInfo = getElement<HTMLDivElement>('cert-info');
if (certInfo) {
certInfo.classList.add('hidden');
}
}
async function handlePasswordInput(): Promise<void> {
const certPassword = getElement<HTMLInputElement>('cert-password');
const password = certPassword?.value ?? '';
if (!state.certFile || !password) {
return;
}
try {
const isPemFile = state.certFile.name.toLowerCase().endsWith('.pem');
if (isPemFile) {
const pemContent = await state.certFile.text();
state.certData = parseCombinedPem(pemContent, password);
} else {
const certBytes = await readFileAsArrayBuffer(state.certFile) as ArrayBuffer;
state.certData = parsePfxFile(certBytes, password);
}
updateCertInfo();
showSignatureOptions();
updateProcessButton();
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
certStatus.innerHTML = 'Certificate unlocked <i data-lucide="check-circle" class="inline w-4 h-4"></i>';
createIcons({ icons });
certStatus.className = 'text-xs text-green-400';
}
} catch (error) {
state.certData = null;
hideSignatureOptions();
updateProcessButton();
const certStatus = getElement<HTMLDivElement>('cert-status');
if (certStatus) {
const errorMessage = error instanceof Error ? error.message : 'Invalid password or certificate';
certStatus.textContent = errorMessage.includes('password')
? 'Incorrect password'
: 'Failed to parse certificate';
certStatus.className = 'text-xs text-red-400';
}
}
}
function updateCertInfo(): void {
if (!state.certData) return;
const certInfo = getElement<HTMLDivElement>('cert-info');
const certSubject = getElement<HTMLSpanElement>('cert-subject');
const certIssuer = getElement<HTMLSpanElement>('cert-issuer');
const certValidity = getElement<HTMLSpanElement>('cert-validity');
if (!certInfo) return;
const info = getCertificateInfo(state.certData.certificate);
if (certSubject) {
certSubject.textContent = info.subject;
}
if (certIssuer) {
certIssuer.textContent = info.issuer;
}
if (certValidity) {
const formatDate = (date: Date) => date.toLocaleDateString();
certValidity.textContent = `${formatDate(info.validFrom)} - ${formatDate(info.validTo)}`;
}
certInfo.classList.remove('hidden');
}
function updateProcessButton(): void {
const processBtn = getElement<HTMLButtonElement>('process-btn');
if (!processBtn) return;
const canProcess = state.pdfBytes !== null && state.certData !== null;
if (canProcess) {
processBtn.style.display = '';
} else {
processBtn.style.display = 'none';
}
}
async function processSignature(): Promise<void> {
if (!state.pdfBytes || !state.certData) {
showAlert('Missing Data', 'Please upload both a PDF and a valid certificate.');
return;
}
const reason = getElement<HTMLInputElement>('sign-reason')?.value ?? '';
const location = getElement<HTMLInputElement>('sign-location')?.value ?? '';
const contactInfo = getElement<HTMLInputElement>('sign-contact')?.value ?? '';
const signatureInfo: SignatureInfo = {};
if (reason) signatureInfo.reason = reason;
if (location) signatureInfo.location = location;
if (contactInfo) signatureInfo.contactInfo = contactInfo;
let visibleSignature: VisibleSignatureOptions | undefined;
const enableVisibleSig = getElement<HTMLInputElement>('enable-visible-sig');
if (enableVisibleSig?.checked) {
const sigX = parseInt(getElement<HTMLInputElement>('sig-x')?.value ?? '25', 10);
const sigY = parseInt(getElement<HTMLInputElement>('sig-y')?.value ?? '700', 10);
const sigWidth = parseInt(getElement<HTMLInputElement>('sig-width')?.value ?? '150', 10);
const sigHeight = parseInt(getElement<HTMLInputElement>('sig-height')?.value ?? '70', 10);
const sigPageSelect = getElement<HTMLSelectElement>('sig-page');
let sigPage: number | string = 0;
let numPages = 1;
try {
const pdfDoc = await getPDFDocument({ data: state.pdfBytes.slice() }).promise;
numPages = pdfDoc.numPages;
} catch (error) {
console.error('Error getting PDF page count:', error);
}
if (sigPageSelect) {
if (sigPageSelect.value === 'last') {
sigPage = (numPages - 1).toString();
} else if (sigPageSelect.value === 'all') {
if (numPages === 1) {
sigPage = '0';
} else {
sigPage = `0-${numPages - 1}`;
}
} else if (sigPageSelect.value === 'custom') {
sigPage = parseInt(getElement<HTMLInputElement>('sig-custom-page')?.value ?? '1', 10) - 1;
} else {
sigPage = parseInt(sigPageSelect.value, 10);
}
}
const enableSigText = getElement<HTMLInputElement>('enable-sig-text');
let sigText = enableSigText?.checked ? getElement<HTMLInputElement>('sig-text')?.value : undefined;
const sigTextColor = getElement<HTMLInputElement>('sig-text-color')?.value ?? '#000000';
const sigTextSize = parseInt(getElement<HTMLInputElement>('sig-text-size')?.value ?? '12', 10);
if (!state.sigImageData && !sigText && state.certData) {
const certInfo = getCertificateInfo(state.certData.certificate);
const date = new Date().toLocaleDateString();
sigText = `Digitally signed by ${certInfo.subject}\n${date}`;
}
let finalHeight = sigHeight;
if (sigText && !state.sigImageData) {
const lineCount = (sigText.match(/\n/g) || []).length + 1;
const lineHeightFactor = 1.4;
const padding = 16;
const calculatedHeight = Math.ceil(lineCount * sigTextSize * lineHeightFactor + padding);
finalHeight = Math.max(calculatedHeight, sigHeight);
}
visibleSignature = {
enabled: true,
x: sigX,
y: sigY,
width: sigWidth,
height: finalHeight,
page: sigPage,
imageData: state.sigImageData ?? undefined,
imageType: state.sigImageType ?? undefined,
text: sigText,
textColor: sigTextColor,
textSize: sigTextSize,
};
}
showLoader('Applying digital signature...');
try {
const signedPdfBytes = await signPdf(state.pdfBytes, state.certData, {
signatureInfo,
visibleSignature,
});
const blob = new Blob([signedPdfBytes.slice().buffer], { type: 'application/pdf' });
const originalName = state.pdfFile?.name ?? 'document.pdf';
const signedName = originalName.replace(/\.pdf$/i, '_signed.pdf');
downloadFile(blob, signedName);
hideLoader();
showAlert('Success', 'PDF signed successfully! The signature can be verified in any PDF reader.', 'success', () => { resetState(); });
} catch (error) {
hideLoader();
console.error('Signing error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
// Check if this is a CORS/network error from certificate chain fetching
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('CORS') || errorMessage.includes('NetworkError')) {
showAlert(
'Signing Failed',
'Failed to fetch certificate chain. This may be due to network issues or the certificate proxy being unavailable. Please check your internet connection and try again. If the issue persists, contact support.'
);
} else {
showAlert('Signing Failed', `Failed to sign PDF: ${errorMessage}`);
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}

View File

@@ -0,0 +1,283 @@
import { PdfSigner, type SignOption } from 'zgapdfsigner';
import forge from 'node-forge';
import { CertificateData, SignPdfOptions } from '@/types';
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData {
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes)));
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const certBagArray = certBags[forge.pki.oids.certBag];
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
if (!certBagArray || certBagArray.length === 0) {
throw new Error('No certificate found in PFX file');
}
if (!keyBagArray || keyBagArray.length === 0) {
throw new Error('No private key found in PFX file');
}
const certificate = certBagArray[0].cert;
if (!certificate) {
throw new Error('Failed to extract certificate from PFX file');
}
return { p12Buffer: pfxBytes, password, certificate };
}
export function parsePemFiles(
certPem: string,
keyPem: string,
keyPassword?: string
): CertificateData {
const certificate = forge.pki.certificateFromPem(certPem);
let privateKey: forge.pki.PrivateKey;
if (keyPem.includes('ENCRYPTED')) {
if (!keyPassword) {
throw new Error('Password required for encrypted private key');
}
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
if (!privateKey) {
throw new Error('Failed to decrypt private key');
}
} else {
privateKey = forge.pki.privateKeyFromPem(keyPem);
}
const p12Password = keyPassword || 'temp-password';
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
privateKey,
[certificate],
p12Password,
{ algorithm: '3des' }
);
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
const p12Buffer = new Uint8Array(p12Der.length);
for (let i = 0; i < p12Der.length; i++) {
p12Buffer[i] = p12Der.charCodeAt(i);
}
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
}
export function parseCombinedPem(pemContent: string, password?: string): CertificateData {
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/);
if (!certMatch) {
throw new Error('No certificate found in PEM file');
}
if (!keyMatch) {
throw new Error('No private key found in PEM file');
}
return parsePemFiles(certMatch[0], keyMatch[0], password);
}
/**
* CORS Proxy URL for fetching external certificates.
* The zgapdfsigner library tries to fetch issuer certificates from external URLs,
* but those servers often don't have CORS headers. This proxy adds the necessary
* CORS headers to allow the requests from the browser.
*
* If you are self-hosting, you MUST deploy your own proxy using cloudflare/cors-proxy-worker.js or any other way of your choice
* and set VITE_CORS_PROXY_URL environment variable.
*
* If not set, certificates requiring external chain fetching will fail.
*/
const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
/**
* Shared secret for signing proxy requests (HMAC-SHA256).
*
* SECURITY NOTE FOR PRODUCTION:
* Client-side secrets are NEVER truly hidden and they can be extracted from
* bundled JavaScript.
*
* For production deployments with sensitive requirements, you should:
* 1. Use your own backend server to proxy certificate requests
* 2. Keep the HMAC secret on your server ONLY (never in frontend code)
* 3. Have your frontend call your server, which then calls the CORS proxy
*
* This client-side HMAC provides limited protection (deters casual abuse)
* but should NOT be considered secure against determined attackers. BentoPDF
* accepts this tradeoff because of it's client side architecture.
*
* To enable (optional):
* 1. Generate a secret: openssl rand -hex 32
* 2. Set PROXY_SECRET on your Cloudflare Worker: npx wrangler secret put PROXY_SECRET
* 3. Set VITE_CORS_PROXY_SECRET in your build environment (must match PROXY_SECRET)
*/
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
async function generateProxySignature(url: string, timestamp: number): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(CORS_PROXY_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const message = `${url}${timestamp}`;
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(message)
);
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Custom fetch wrapper that routes external certificate requests through a CORS proxy.
* The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the
* certificate's AIA extension. When those servers don't have CORS enabled (like www.cert.fnmt.es),
* the fetch fails. This wrapper routes such requests through our CORS proxy.
*
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
*/
function createCorsAwareFetch(): {
wrappedFetch: typeof fetch;
restore: () => void;
} {
const originalFetch = window.fetch.bind(window);
const wrappedFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const isExternalCertificateUrl = (
url.includes('.crt') ||
url.includes('.cer') ||
url.includes('.pem') ||
url.includes('/certs/') ||
url.includes('/ocsp') ||
url.includes('/crl') ||
url.includes('caIssuers')
) && !url.startsWith(window.location.origin);
if (isExternalCertificateUrl && CORS_PROXY_URL) {
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
if (CORS_PROXY_SECRET) {
const timestamp = Date.now();
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`);
} else {
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
}
return originalFetch(proxyUrl, init);
}
return originalFetch(input, init);
};
window.fetch = wrappedFetch;
return {
wrappedFetch,
restore: () => {
window.fetch = originalFetch;
}
};
}
export async function signPdf(
pdfBytes: Uint8Array,
certificateData: CertificateData,
options: SignPdfOptions = {}
): Promise<Uint8Array> {
const signatureInfo = options.signatureInfo ?? {};
const signOptions: SignOption = {
p12cert: certificateData.p12Buffer,
pwd: certificateData.password,
};
if (signatureInfo.reason) {
signOptions.reason = signatureInfo.reason;
}
if (signatureInfo.location) {
signOptions.location = signatureInfo.location;
}
if (signatureInfo.contactInfo) {
signOptions.contact = signatureInfo.contactInfo;
}
if (options.visibleSignature?.enabled) {
const vs = options.visibleSignature;
const drawinf = {
area: {
x: vs.x,
y: vs.y,
w: vs.width,
h: vs.height,
},
pageidx: vs.page,
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
textInfo: undefined as { text: string; size: number; color: string } | undefined,
};
if (vs.imageData && vs.imageType) {
drawinf.imgInfo = {
imgData: vs.imageData,
imgType: vs.imageType,
};
}
if (vs.text) {
drawinf.textInfo = {
text: vs.text,
size: vs.textSize ?? 12,
color: vs.textColor ?? '#000000',
};
}
signOptions.drawinf = drawinf as SignOption['drawinf'];
}
const signer = new PdfSigner(signOptions);
const { restore } = createCorsAwareFetch();
try {
const signedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(signedPdfBytes);
} finally {
restore();
}
}
export function getCertificateInfo(certificate: forge.pki.Certificate): {
subject: string;
issuer: string;
validFrom: Date;
validTo: Date;
serialNumber: string;
} {
const subjectCN = certificate.subject.getField('CN');
const issuerCN = certificate.issuer.getField('CN');
return {
subject: subjectCN?.value as string ?? 'Unknown',
issuer: issuerCN?.value as string ?? 'Unknown',
validFrom: certificate.validity.notBefore,
validTo: certificate.validity.notAfter,
serialNumber: certificate.serialNumber,
};
}

View File

@@ -1,14 +1,9 @@
import { DividePagesState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes, parsePageRanges } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
interface DividePagesState {
file: File | null;
pdfDoc: PDFLibDocument | null;
totalPages: number;
}
const pageState: DividePagesState = {
file: null,
pdfDoc: null,

View File

@@ -1,22 +1,10 @@
import { EditAttachmentState, AttachmentInfo } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
const worker = new Worker(import.meta.env.BASE_URL + 'workers/edit-attachments.worker.js');
interface AttachmentInfo {
index: number;
name: string;
page: number;
data: Uint8Array;
}
interface EditAttachmentState {
file: File | null;
allAttachments: AttachmentInfo[];
attachmentsToRemove: Set<number>;
}
const pageState: EditAttachmentState = {
file: null,
allAttachments: [],

View File

@@ -1,13 +1,9 @@
import { EditMetadataState } from '@/types';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import { PDFDocument as PDFLibDocument, PDFName, PDFString } from 'pdf-lib';
interface EditMetadataState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}
const pageState: EditMetadataState = {
file: null,
pdfDoc: null,

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { EncryptPdfState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: EncryptPdfState = {
file: null,
};

View File

@@ -2,12 +2,9 @@ import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, hexToRgb } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb, PageSizes } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { FixPageSizeState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: FixPageSizeState = {
file: null,
};

View File

@@ -3,12 +3,9 @@ import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpe
import { PDFDocument } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { FlattenPdfState } from '@/types';
interface PageState {
files: File[];
}
const pageState: PageState = {
const pageState: FlattenPdfState = {
files: [],
};

View File

@@ -14,8 +14,8 @@ import { FormField, PageData } from '../types/index.js'
let fields: FormField[] = []
let selectedField: FormField | null = null
let fieldCounter = 0
let existingFieldNames: Set<string> = new Set()
let existingRadioGroups: Set<string> = new Set()
const existingFieldNames: Set<string> = new Set()
const existingRadioGroups: Set<string> = new Set()
let draggedElement: HTMLElement | null = null
let offsetX = 0
let offsetY = 0
@@ -2045,7 +2045,7 @@ async function renderCanvas(): Promise<void> {
if (!currentPage) return
// Fixed scale for better visibility
let scale = 1.333
const scale = 1.333
currentScale = scale

View File

@@ -85,7 +85,7 @@ function resetState() {
}
const toolUploader = document.getElementById('tool-uploader');
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true';
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-6xl');
toolUploader.classList.add('max-w-2xl');
@@ -139,7 +139,8 @@ async function setupFormViewer() {
}
const toolUploader = document.getElementById('tool-uploader');
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true';
// Default to true if not set
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
if (toolUploader && !isFullWidth) {
toolUploader.classList.remove('max-w-2xl');
toolUploader.classList.add('max-w-6xl');

View File

@@ -2,9 +2,9 @@ import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes, parsePageRanges } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument, rgb, StandardFonts } from 'pdf-lib';
import { HeaderFooterState } from '@/types';
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: HeaderFooterState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -3,6 +3,7 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
import heic2any from 'heic2any';
const SUPPORTED_FORMATS = '.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.pnm,.pgm,.pbm,.ppm,.pam,.jxr,.jpx,.jp2,.psd,.svg,.heic,.heif,.webp';
const SUPPORTED_FORMATS_DISPLAY = 'JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP';
@@ -172,20 +173,93 @@ async function ensurePyMuPDF(): Promise<PyMuPDF> {
return pymupdf;
}
async function preprocessFile(file: File): Promise<File> {
const ext = getFileExtension(file.name);
if (ext === '.heic' || ext === '.heif') {
try {
const conversionResult = await heic2any({
blob: file,
toType: 'image/png',
quality: 0.9,
});
const blob = Array.isArray(conversionResult) ? conversionResult[0] : conversionResult;
return new File([blob], file.name.replace(/\.(heic|heif)$/i, '.png'), { type: 'image/png' });
} catch (e) {
console.error(`Failed to convert HEIC: ${file.name}`, e);
throw new Error(`Failed to process HEIC file: ${file.name}`);
}
}
if (ext === '.webp') {
try {
return await new Promise<File>((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Canvas context failed'));
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
URL.revokeObjectURL(url);
if (blob) {
resolve(new File([blob], file.name.replace(/\.webp$/i, '.png'), { type: 'image/png' }));
} else {
reject(new Error('Canvas toBlob failed'));
}
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load WebP image'));
};
img.src = url;
});
} catch (e) {
console.error(`Failed to convert WebP: ${file.name}`, e);
throw new Error(`Failed to process WebP file: ${file.name}`);
}
}
return file;
}
async function convertToPdf() {
if (files.length === 0) {
showAlert('No Files', 'Please select at least one image file.');
return;
}
showLoader('Loading PyMuPDF engine...');
showLoader('Processing images...');
try {
const processedFiles: File[] = [];
for (const file of files) {
try {
const processed = await preprocessFile(file);
processedFiles.push(processed);
} catch (error: any) {
console.warn(error);
throw error;
}
}
showLoader('Loading engine...');
const mupdf = await ensurePyMuPDF();
showLoader('Converting images to PDF...');
const pdfBlob = await mupdf.imagesToPdf(files);
const pdfBlob = await mupdf.imagesToPdf(processedFiles);
downloadFile(pdfBlob, 'images_to_pdf.pdf');

View File

@@ -3,11 +3,11 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { InvertColorsState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: InvertColorsState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); }
else { initializePage(); }

View File

@@ -172,7 +172,7 @@ async function convertToPdf() {
return;
}
showLoader('Loading PyMuPDF engine...');
showLoader('Loading engine...');
try {
const mupdf = await ensurePyMuPDF();

View File

@@ -2,12 +2,9 @@ import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { LinearizePdfState } from '@/types';
interface PageState {
files: File[];
}
const pageState: PageState = {
const pageState: LinearizePdfState = {
files: [],
};

View File

@@ -7,20 +7,10 @@ import fontkit from '@pdf-lib/fontkit';
import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import { getFontForLanguage } from '../utils/font-loader.js';
import { OcrWord, OcrState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface Word {
text: string;
bbox: { x0: number; y0: number; x1: number; y1: number };
confidence: number;
}
interface OcrState {
file: File | null;
searchablePdfBytes: Uint8Array | null;
}
const pageState: OcrState = {
file: null,
searchablePdfBytes: null,
@@ -35,10 +25,10 @@ const whitelistPresets: Record<string, string> = {
forms: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:',
};
function parseHOCR(hocrText: string): Word[] {
function parseHOCR(hocrText: string): OcrWord[] {
const parser = new DOMParser();
const doc = parser.parseFromString(hocrText, 'text/html');
const words: Word[] = [];
const words: OcrWord[] = [];
const wordElements = doc.querySelectorAll('.ocrx_word');
@@ -264,7 +254,7 @@ async function runOCR() {
if (data.hocr) {
const words = parseHOCR(data.hocr);
words.forEach(function (word: Word) {
words.forEach(function (word: OcrWord) {
const { x0, y0, x1, y1 } = word.bbox;
const text = word.text.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '');

View File

@@ -2,13 +2,9 @@ import { showAlert } from '../ui.js';
import { formatBytes, getStandardPageName, convertPoints } from '../utils/helpers.js';
import { PDFDocument } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { PageDimensionsState } from '@/types';
interface PageState {
file: File | null;
pdfDoc: PDFDocument | null;
}
const pageState: PageState = {
const pageState: PageDimensionsState = {
file: null,
pdfDoc: null,
};

View File

@@ -162,7 +162,7 @@ async function addPageNumbers() {
const xOffset = bounds.x || 0;
const yOffset = bounds.y || 0;
let pageNumText = format === 'page_x_of_y' ? `${i + 1} / ${totalPages}` : `${i + 1}`;
const pageNumText = format === 'page_x_of_y' ? `${i + 1} / ${totalPages}` : `${i + 1}`;
const textWidth = helveticaFont.widthOfTextAtSize(pageNumText, fontSize);
const textHeight = fontSize;

View File

@@ -19,7 +19,7 @@ interface LayerData {
let currentFile: File | null = null;
let currentDoc: any = null;
let layersMap = new Map<number, LayerData>();
const layersMap = new Map<number, LayerData>();
let nextDisplayOrder = 0;
document.addEventListener('DOMContentLoaded', () => {
@@ -271,7 +271,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
try {
showLoader('Loading PyMuPDF...');
showLoader('Loading engine...');
await pymupdf.load();
showLoader(`Loading layers from ${currentFile.name}...`);

View File

@@ -3,17 +3,10 @@ import { downloadFile, parsePageRanges, getPDFDocument, formatBytes } from '../u
import { PDFDocument, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { createIcons, icons } from 'lucide';
import { PosterizeState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface PosterizeState {
file: File | null;
pdfJsDoc: pdfjsLib.PDFDocumentProxy | null;
pdfBytes: Uint8Array | null;
pageSnapshots: Record<number, ImageData>;
currentPage: number;
}
const pageState: PosterizeState = {
file: null,
pdfJsDoc: null,
@@ -143,7 +136,7 @@ async function posterize() {
const rows = parseInt((document.getElementById('posterize-rows') as HTMLInputElement).value) || 1;
const cols = parseInt((document.getElementById('posterize-cols') as HTMLInputElement).value) || 1;
const pageSizeKey = (document.getElementById('output-page-size') as HTMLSelectElement).value as keyof typeof PageSizes;
let orientation = (document.getElementById('output-orientation') as HTMLSelectElement).value;
const orientation = (document.getElementById('output-orientation') as HTMLSelectElement).value;
const scalingMode = (document.querySelector('input[name="scaling-mode"]:checked') as HTMLInputElement).value;
const overlap = parseFloat((document.getElementById('overlap') as HTMLInputElement).value) || 0;
const overlapUnits = (document.getElementById('overlap-units') as HTMLSelectElement).value;

View File

@@ -94,7 +94,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
showLoader('Loading PyMuPDF...');
showLoader('Loading engine...');
await pymupdf.load();
const total = state.files.length;

View File

@@ -82,7 +82,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
try {
showLoader('Loading PyMuPDF engine...');
showLoader('Loading engine...');
const mupdf = await ensurePyMuPDF();
if (state.files.length === 1) {

View File

@@ -94,7 +94,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
showLoader('Loading PyMuPDF...');
showLoader('Loading engine...');
await pymupdf.load();
// Get options from UI

View File

@@ -1,12 +1,9 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
import { icons, createIcons } from 'lucide';
import { RemoveRestrictionsState } from '@/types';
interface PageState {
file: File | null;
}
const pageState: PageState = {
const pageState: RemoveRestrictionsState = {
file: null,
};

View File

@@ -2,13 +2,9 @@ import { showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PDFDocument, PDFName } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { SanitizePdfState } from '@/types';
interface PageState {
file: File | null;
pdfDoc: PDFDocument | null;
}
const pageState: PageState = {
const pageState: SanitizePdfState = {
file: null,
pdfDoc: null,
};

View File

@@ -3,11 +3,11 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes, getPDFDocument, readFileAsArrayBuffer } from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { TextColorState } from '@/types';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
interface PageState { file: File | null; pdfDoc: PDFLibDocument | null; }
const pageState: PageState = { file: null, pdfDoc: null };
const pageState: TextColorState = { file: null, pdfDoc: null };
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); }
else { initializePage(); }

View File

@@ -0,0 +1,469 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
import { validatePdfSignatures } from './validate-signature-pdf.js';
import forge from 'node-forge';
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
const state: ValidateSignatureState = {
pdfFile: null,
pdfBytes: null,
results: [],
trustedCertFile: null,
trustedCert: null,
};
function getElement<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
function resetState(): void {
state.pdfFile = null;
state.pdfBytes = null;
state.results = [];
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const resultsSection = getElement<HTMLDivElement>('results-section');
if (resultsSection) resultsSection.classList.add('hidden');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (resultsContainer) resultsContainer.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.add('hidden');
}
function resetCertState(): void {
state.trustedCertFile = null;
state.trustedCert = null;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
}
function initializePage(): void {
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
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) {
handlePdfFile(droppedFiles[0]);
}
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
}
function handlePdfUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
}
async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
resetState();
state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
updatePdfDisplay();
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.remove('hidden');
createIcons({ icons });
await validateSignatures();
}
function updatePdfDisplay(): void {
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(state.pdfFile.size);
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 = () => resetState();
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
function handleCertUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
}
async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.');
return;
}
resetCertState();
state.trustedCertFile = file;
try {
const content = await file.text();
if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content);
} else {
const bytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
}
updateCertDisplay();
if (state.pdfBytes) {
await validateSignatures();
}
} catch (error) {
console.error('Error parsing certificate:', error);
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
resetCertState();
}
}
function updateCertDisplay(): void {
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML = '<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
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 = async () => {
resetCertState();
if (state.pdfBytes) {
await validateSignatures();
}
};
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
}
async function validateSignatures(): Promise<void> {
if (!state.pdfBytes) return;
showLoader('Analyzing signatures...');
try {
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined);
displayResults();
} catch (error) {
console.error('Validation error:', error);
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.');
} finally {
hideLoader();
}
}
function displayResults(): void {
const resultsSection = getElement<HTMLDivElement>('results-section');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (!resultsSection || !resultsContainer) return;
resultsContainer.innerHTML = '';
resultsSection.classList.remove('hidden');
if (state.results.length === 0) {
resultsContainer.innerHTML = `
<div class="bg-gray-700 rounded-lg p-6 text-center border border-gray-600">
<i data-lucide="file-x" class="w-12 h-12 mx-auto mb-4 text-gray-400"></i>
<h3 class="text-lg font-semibold text-white mb-2">No Signatures Found</h3>
<p class="text-gray-400">This PDF does not contain any digital signatures.</p>
</div>
`;
createIcons({ icons });
return;
}
const summaryDiv = document.createElement('div');
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const validCount = state.results.filter(r => r.isValid && !r.isExpired).length;
const trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0;
let summaryHtml = `
<p class="text-gray-300">
<span class="font-semibold text-white">${state.results.length}</span>
signature${state.results.length > 1 ? 's' : ''} found
<span class="text-gray-500">•</span>
<span class="${validCount === state.results.length ? 'text-green-400' : 'text-yellow-400'}">${validCount} valid</span>
</p>
`;
if (state.trustedCert) {
summaryHtml += `
<p class="text-xs text-gray-400 mt-1">
<i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>
Trust verification: ${trustVerified}/${state.results.length} signatures verified against custom certificate
</p>
`;
}
summaryDiv.innerHTML = summaryHtml;
resultsContainer.appendChild(summaryDiv);
state.results.forEach((result, index) => {
const card = createSignatureCard(result, index);
resultsContainer.appendChild(card);
});
createIcons({ icons });
}
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement {
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = 'Valid Signature';
if (!result.isValid) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid Signature';
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
}
const formatDate = (date: Date) => {
if (!date || date.getTime() === 0) return 'Unknown';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
let trustBadge = '';
if (state.trustedCert) {
if (result.isTrusted) {
trustBadge = '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else {
trustBadge = '<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
}
}
card.innerHTML = `
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
<div>
<h3 class="font-semibold text-white">Signature ${index + 1}</h3>
<p class="text-sm ${statusColor}">${statusText}</p>
</div>
</div>
<div class="flex items-center">
${result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
: ''
}${trustBadge}
</div>
</div>
<div class="space-y-3 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-gray-400">Signed By</p>
<p class="text-white font-medium">${escapeHtml(result.signerName)}</p>
${result.signerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerOrg)}</p>` : ''}
${result.signerEmail ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerEmail)}</p>` : ''}
</div>
<div>
<p class="text-gray-400">Issuer</p>
<p class="text-white font-medium">${escapeHtml(result.issuer)}</p>
${result.issuerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.issuerOrg)}</p>` : ''}
</div>
</div>
${result.signatureDate ? `
<div>
<p class="text-gray-400">Signed On</p>
<p class="text-white">${formatDate(result.signatureDate)}</p>
</div>
` : ''}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-gray-400">Valid From</p>
<p class="text-white">${formatDate(result.validFrom)}</p>
</div>
<div>
<p class="text-gray-400">Valid Until</p>
<p class="${result.isExpired ? 'text-red-400' : 'text-white'}">${formatDate(result.validTo)}</p>
</div>
</div>
${result.reason ? `
<div>
<p class="text-gray-400">Reason</p>
<p class="text-white">${escapeHtml(result.reason)}</p>
</div>
` : ''}
${result.location ? `
<div>
<p class="text-gray-400">Location</p>
<p class="text-white">${escapeHtml(result.location)}</p>
</div>
` : ''}
<details class="mt-2">
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
Technical Details
</summary>
<div class="mt-2 p-3 bg-gray-800 rounded text-xs space-y-1">
<p><span class="text-gray-400">Serial Number:</span> <span class="text-gray-300 font-mono">${escapeHtml(result.serialNumber)}</span></p>
<p><span class="text-gray-400">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
</div>
</details>
</div>
`;
return card;
}
function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}

View File

@@ -0,0 +1,238 @@
import forge from 'node-forge';
import { ExtractedSignature, SignatureValidationResult } from '@/types';
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
const signatures: ExtractedSignature[] = [];
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
// Find all signature objects for /Type /Sig
const sigRegex = /\/Type\s*\/Sig\b/g;
let sigMatch;
let sigIndex = 0;
while ((sigMatch = sigRegex.exec(pdfString)) !== null) {
try {
const searchStart = Math.max(0, sigMatch.index - 5000);
const searchEnd = Math.min(pdfString.length, sigMatch.index + 10000);
const context = pdfString.substring(searchStart, searchEnd);
const byteRangeMatch = context.match(/\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/);
if (!byteRangeMatch) continue;
const byteRange = [
parseInt(byteRangeMatch[1], 10),
parseInt(byteRangeMatch[2], 10),
parseInt(byteRangeMatch[3], 10),
parseInt(byteRangeMatch[4], 10),
];
const contentsMatch = context.match(/\/Contents\s*<([0-9A-Fa-f]+)>/);
if (!contentsMatch) continue;
const hexContents = contentsMatch[1];
const contentsBytes = hexToBytes(hexContents);
const reasonMatch = context.match(/\/Reason\s*\(([^)]*)\)/);
const locationMatch = context.match(/\/Location\s*\(([^)]*)\)/);
const contactMatch = context.match(/\/ContactInfo\s*\(([^)]*)\)/);
const nameMatch = context.match(/\/Name\s*\(([^)]*)\)/);
const timeMatch = context.match(/\/M\s*\(D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
let signingTime: string | undefined;
if (timeMatch) {
signingTime = `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`;
}
signatures.push({
index: sigIndex++,
contents: contentsBytes,
byteRange,
reason: reasonMatch ? decodeURIComponent(escape(reasonMatch[1])) : undefined,
location: locationMatch ? decodeURIComponent(escape(locationMatch[1])) : undefined,
contactInfo: contactMatch ? decodeURIComponent(escape(contactMatch[1])) : undefined,
name: nameMatch ? decodeURIComponent(escape(nameMatch[1])) : undefined,
signingTime,
});
} catch (e) {
console.warn('Error extracting signature at index', sigIndex, e);
}
}
return signatures;
}
export function validateSignature(
signature: ExtractedSignature,
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): SignatureValidationResult {
const result: SignatureValidationResult = {
signatureIndex: signature.index,
isValid: false,
signerName: 'Unknown',
issuer: 'Unknown',
validFrom: new Date(0),
validTo: new Date(0),
isExpired: false,
isSelfSigned: false,
isTrusted: false,
algorithms: { digest: 'Unknown', signature: 'Unknown' },
serialNumber: '',
byteRange: signature.byteRange,
coverageStatus: 'unknown',
reason: signature.reason,
location: signature.location,
contactInfo: signature.contactInfo,
};
try {
const binaryString = String.fromCharCode.apply(null, Array.from(signature.contents));
const asn1 = forge.asn1.fromDer(binaryString);
const p7 = forge.pkcs7.messageFromAsn1(asn1) as any;
if (!p7.certificates || p7.certificates.length === 0) {
result.errorMessage = 'No certificates found in signature';
return result;
}
const signerCert = p7.certificates[0] as forge.pki.Certificate;
const subjectCN = signerCert.subject.getField('CN');
const subjectO = signerCert.subject.getField('O');
const subjectE = signerCert.subject.getField('E') || signerCert.subject.getField('emailAddress');
const issuerCN = signerCert.issuer.getField('CN');
const issuerO = signerCert.issuer.getField('O');
result.signerName = (subjectCN?.value as string) ?? 'Unknown';
result.signerOrg = subjectO?.value as string | undefined;
result.signerEmail = subjectE?.value as string | undefined;
result.issuer = (issuerCN?.value as string) ?? 'Unknown';
result.issuerOrg = issuerO?.value as string | undefined;
result.validFrom = signerCert.validity.notBefore;
result.validTo = signerCert.validity.notAfter;
result.serialNumber = signerCert.serialNumber;
const now = new Date();
result.isExpired = now > result.validTo || now < result.validFrom;
result.isSelfSigned = signerCert.isIssuer(signerCert);
// Check trust against provided certificate
if (trustedCert) {
try {
const isTrustedIssuer = trustedCert.isIssuer(signerCert);
const isSameCert = signerCert.serialNumber === trustedCert.serialNumber;
let chainTrusted = false;
for (const cert of p7.certificates) {
if (trustedCert.isIssuer(cert) ||
(cert as forge.pki.Certificate).serialNumber === trustedCert.serialNumber) {
chainTrusted = true;
break;
}
}
result.isTrusted = isTrustedIssuer || isSameCert || chainTrusted;
} catch {
result.isTrusted = false;
}
}
result.algorithms = {
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
};
// Parse signing time if available in signature
if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime);
} else {
// Try to extract from authenticated attributes
try {
const signedData = p7 as any;
if (signedData.rawCapture?.authenticatedAttributes) {
// Look for signing time attribute
for (const attr of signedData.rawCapture.authenticatedAttributes) {
if (attr.type === forge.pki.oids.signingTime) {
result.signatureDate = attr.value;
break;
}
}
}
} catch { /* ignore */ }
}
if (signature.byteRange && signature.byteRange.length === 4) {
const [start1, len1, start2, len2] = signature.byteRange;
const totalCovered = len1 + len2;
const expectedEnd = start2 + len2;
if (expectedEnd === pdfBytes.length) {
result.coverageStatus = 'full';
} else if (expectedEnd < pdfBytes.length) {
result.coverageStatus = 'partial';
}
}
result.isValid = true;
} catch (e) {
result.errorMessage = e instanceof Error ? e.message : 'Failed to parse signature';
}
return result;
}
export async function validatePdfSignatures(
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult[]> {
const signatures = extractSignatures(pdfBytes);
return signatures.map(sig => validateSignature(sig, pdfBytes, trustedCert));
}
export function countSignatures(pdfBytes: Uint8Array): number {
return extractSignatures(pdfBytes).length;
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
let actualLength = bytes.length;
while (actualLength > 0 && bytes[actualLength - 1] === 0) {
actualLength--;
}
return bytes.slice(0, actualLength);
}
function getDigestAlgorithmName(oid: string): string {
const digestAlgorithms: Record<string, string> = {
'1.2.840.113549.2.5': 'MD5',
'1.3.14.3.2.26': 'SHA-1',
'2.16.840.1.101.3.4.2.1': 'SHA-256',
'2.16.840.1.101.3.4.2.2': 'SHA-384',
'2.16.840.1.101.3.4.2.3': 'SHA-512',
'2.16.840.1.101.3.4.2.4': 'SHA-224',
};
return digestAlgorithms[oid] || oid || 'Unknown';
}
function getSignatureAlgorithmName(oid: string): string {
const signatureAlgorithms: Record<string, string> = {
'1.2.840.113549.1.1.1': 'RSA',
'1.2.840.113549.1.1.5': 'RSA with SHA-1',
'1.2.840.113549.1.1.11': 'RSA with SHA-256',
'1.2.840.113549.1.1.12': 'RSA with SHA-384',
'1.2.840.113549.1.1.13': 'RSA with SHA-512',
'1.2.840.10045.2.1': 'ECDSA',
'1.2.840.10045.4.1': 'ECDSA with SHA-1',
'1.2.840.10045.4.3.2': 'ECDSA with SHA-256',
'1.2.840.10045.4.3.3': 'ECDSA with SHA-384',
'1.2.840.10045.4.3.4': 'ECDSA with SHA-512',
};
return signatureAlgorithms[oid] || oid || 'Unknown';
}

View File

@@ -1,11 +1,7 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { formatBytes, formatIsoDate, getPDFDocument } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
interface ViewMetadataState {
file: File | null;
metadata: Record<string, unknown>;
}
import { ViewMetadataState } from '@/types';
const pageState: ViewMetadataState = {
file: null,

View File

@@ -102,7 +102,7 @@ const init = async () => {
<span class="text-white font-bold text-lg">BentoPDF</span>
</div>
<p class="text-gray-400 text-sm">
&copy; 2025 BentoPDF. All rights reserved.
&copy; 2026 BentoPDF. All rights reserved.
</p>
<p class="text-gray-500 text-xs mt-2">
Version <span id="app-version-simple">${APP_VERSION}</span>
@@ -321,30 +321,63 @@ const init = async () => {
const searchBar = document.getElementById('search-bar');
const categoryGroups = dom.toolGrid.querySelectorAll('.category-group');
const searchResultsContainer = document.createElement('div');
searchResultsContainer.id = 'search-results';
searchResultsContainer.className = 'hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6 col-span-full';
dom.toolGrid.insertBefore(searchResultsContainer, dom.toolGrid.firstChild);
searchBar.addEventListener('input', () => {
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
const searchTerm = searchBar.value.toLowerCase().trim();
if (!searchTerm) {
searchResultsContainer.classList.add('hidden');
searchResultsContainer.innerHTML = '';
categoryGroups.forEach((group) => {
(group as HTMLElement).style.display = '';
const toolCards = group.querySelectorAll('.tool-card');
toolCards.forEach((card) => {
(card as HTMLElement).style.display = '';
});
});
return;
}
categoryGroups.forEach((group) => {
(group as HTMLElement).style.display = 'none';
});
searchResultsContainer.innerHTML = '';
searchResultsContainer.classList.remove('hidden');
const seenToolIds = new Set<string>();
const allTools: HTMLElement[] = [];
categoryGroups.forEach((group) => {
const toolCards = Array.from(group.querySelectorAll('.tool-card'));
let visibleToolsInCategory = 0;
toolCards.forEach((card) => {
const toolName = (card.querySelector('h3')?.textContent || '').toLowerCase();
const toolSubtitle = (card.querySelector('p')?.textContent || '').toLowerCase();
const toolHref = (card as HTMLAnchorElement).href || (card as HTMLElement).dataset.toolId || '';
const isMatch = !searchTerm || toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
const toolId = toolHref.split('/').pop()?.replace('.html', '') || toolName;
(card as HTMLElement).style.display = isMatch ? '' : 'none';
const isMatch = toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
const isDuplicate = seenToolIds.has(toolId);
if (isMatch) {
visibleToolsInCategory++;
if (isMatch && !isDuplicate) {
seenToolIds.add(toolId);
allTools.push(card.cloneNode(true) as HTMLElement);
}
});
(group as HTMLElement).style.display = visibleToolsInCategory === 0 ? 'none' : '';
});
allTools.forEach((tool) => {
searchResultsContainer.appendChild(tool);
});
createIcons({ icons });
});
window.addEventListener('keydown', function (e) {
@@ -465,8 +498,7 @@ const init = async () => {
const fullWidthToggle = document.getElementById('full-width-toggle') as HTMLInputElement;
const toolInterface = document.getElementById('tool-interface');
// Load saved preference
const savedFullWidth = localStorage.getItem('fullWidthMode') === 'true';
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
if (fullWidthToggle) {
fullWidthToggle.checked = savedFullWidth;
applyFullWidthMode(savedFullWidth);

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddBlankPageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddWatermarkState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,5 @@
export interface AlternateMergeState {
files: File[];
pdfBytes: Map<string, ArrayBuffer>;
pdfDocs: Map<string, any>;
}

View File

@@ -0,0 +1,7 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddAttachmentState {
file: File | null;
pdfDoc: PDFLibDocument | null;
attachments: File[];
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface BackgroundColorState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,3 @@
export interface ChangePermissionsState {
file: File | null;
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface CombineSinglePageState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,9 @@
import * as pdfjsLib from 'pdfjs-dist';
export interface CompareState {
pdfDoc1: pdfjsLib.PDFDocumentProxy | null;
pdfDoc2: pdfjsLib.PDFDocumentProxy | null;
currentPage: number;
viewMode: 'overlay' | 'side-by-side';
isSyncScroll: boolean;
}

View File

@@ -0,0 +1,8 @@
export interface CropperState {
pdfDoc: any;
currentPageNum: number;
cropper: any;
originalPdfBytes: ArrayBuffer | null;
pageCrops: Record<number, any>;
file: File | null;
}

View File

@@ -0,0 +1,3 @@
export interface DecryptPdfState {
file: File | null;
}

View File

@@ -0,0 +1,7 @@
export interface DeletePagesState {
file: File | null;
pdfDoc: any;
pdfJsDoc: any;
totalPages: number;
pagesToDelete: Set<number>;
}

View File

@@ -0,0 +1,42 @@
import { forge } from "zgapdfsigner";
export interface SignatureInfo {
reason?: string;
location?: string;
contactInfo?: string;
name?: string;
}
export interface CertificateData {
p12Buffer: ArrayBuffer;
password: string;
certificate: forge.pki.Certificate;
}
export interface SignPdfOptions {
signatureInfo?: SignatureInfo;
visibleSignature?: VisibleSignatureOptions;
}
export interface VisibleSignatureOptions {
enabled: boolean;
imageData?: ArrayBuffer;
imageType?: 'png' | 'jpeg' | 'webp';
x: number;
y: number;
width: number;
height: number;
page: number | string;
text?: string;
textColor?: string;
textSize?: number;
}
export interface DigitalSignState {
pdfFile: File | null;
pdfBytes: Uint8Array | null;
certFile: File | null;
certData: CertificateData | null;
sigImageData: ArrayBuffer | null;
sigImageType: 'png' | 'jpeg' | 'webp' | null;
}

View File

@@ -0,0 +1,7 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface DividePagesState {
file: File | null;
pdfDoc: PDFLibDocument | null;
totalPages: number;
}

View File

@@ -0,0 +1,12 @@
export interface AttachmentInfo {
index: number;
name: string;
page: number;
data: Uint8Array;
}
export interface EditAttachmentState {
file: File | null;
allAttachments: AttachmentInfo[];
attachmentsToRemove: Set<number>;
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface EditMetadataState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,3 @@
export interface EncryptPdfState {
file: File | null;
}

View File

@@ -0,0 +1,3 @@
export interface ExtractAttachmentsState {
files: File[];
}

View File

@@ -0,0 +1,7 @@
export interface ExtractedImage {
data: Blob;
width: number;
height: number;
pageNumber: number;
imageIndex: number;
}

View File

@@ -0,0 +1,5 @@
export interface ExtractPagesState {
file: File | null;
pdfBytes: ArrayBuffer | null;
totalPages: number;
}

View File

@@ -0,0 +1,3 @@
export interface FixPageSizeState {
file: File | null;
}

View File

@@ -0,0 +1,3 @@
export interface FlattenPdfState {
files: File[];
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface HeaderFooterState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -1,2 +1,48 @@
export * from './ocr.js';
export * from './form-creator.js';
export * from './ocr-type.js';
export * from './form-creator-type.js';
export * from './digital-sign-type.js';
export * from './attachment-type.js';
export * from './edit-attachments-type.js';
export * from './edit-metadata-type.js';
export * from './divide-pages-type.js';
export * from './alternate-merge-page-type.js';
export * from './add-blank-page-type.js';
export * from './compare-pdfs-type.js';
export * from './fix-page-size-type.js';
export * from './view-metadata-type.js';
export * from './header-footer-type.js';
export * from './encrypt-pdf-type.js';
export * from './flatten-pdf-type.js';
export * from './crop-pdf-type.js';
export * from './background-color-type.js';
export * from './posterize-type.js';
export * from './decrypt-pdf-type.js';
export * from './combine-single-page-type.js';
export * from './change-permissions-type.js';
export * from './validate-signature-type.js';
export * from './remove-restrictions-type.js';
export * from './page-dimensions-type.js';
export * from './extract-attachments-type.js';
export * from './pdf-multi-tool-type.js';
export * from './ocr-pdf-type.js';
export * from './delete-pages-type.js';
export * from './invert-colors-type.js';
export * from './table-of-contents-type.js';
export * from './organize-pdf-type.js';
export * from './merge-pdf-type.js';
export * from './extract-images-type.js';
export * from './extract-pages-type.js';
export * from './pdf-layers-type.js';
export * from './sanitize-pdf-type.js';
export * from './reverse-pages-type.js';
export * from './text-color-type.js';
export * from './n-up-pdf-type.js';
export * from './linearize-pdf-type.js';
export * from './remove-metadata-type.js';
export * from './rotate-pdf-type.js';
export * from './pdf-booklet-type.js';
export * from './page-numbers-type.js';
export * from './pdf-to-zip-type.js';
export * from './sign-pdf-type.js';
export * from './add-watermark-type.js';

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface InvertColorsState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,3 @@
export interface LinearizePdfState {
files: File[];
}

View File

@@ -0,0 +1,5 @@
export interface MergeState {
files: File[];
pdfBytes: Map<string, ArrayBuffer>;
pdfDocs: Map<string, any>;
}

View File

@@ -0,0 +1,5 @@
export interface NUpState {
file: File | null;
pdfBytes: ArrayBuffer | null;
totalPages: number;
}

View File

@@ -0,0 +1,10 @@
export interface OcrWord {
text: string;
bbox: { x0: number; y0: number; x1: number; y1: number };
confidence: number;
}
export interface OcrState {
file: File | null;
searchablePdfBytes: Uint8Array | null;
}

View File

@@ -0,0 +1,6 @@
export interface OrganizeState {
file: File | null;
pdfBytes: ArrayBuffer | null;
totalPages: number;
pageOrder: number[];
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument } from 'pdf-lib';
export interface PageDimensionsState {
file: File | null;
pdfDoc: PDFDocument | null;
}

View File

@@ -0,0 +1,3 @@
export interface PageNumbersState {
file: File | null;
}

View File

@@ -0,0 +1,5 @@
export interface BookletState {
file: File | null;
pdfBytes: ArrayBuffer | null;
totalPages: number;
}

View File

@@ -0,0 +1,5 @@
export interface LayerData {
name: string;
visible: boolean;
locked: boolean;
}

View File

@@ -0,0 +1,10 @@
export interface MultiToolPageData {
id: string;
originalPdfId: string;
pageIndex: number;
thumbnail: string;
width: number;
height: number;
rotation: number;
isBlank?: boolean;
}

View File

@@ -0,0 +1,3 @@
export interface PdfToZipState {
files: File[];
}

View File

@@ -0,0 +1,9 @@
import * as pdfjsLib from 'pdfjs-dist';
export interface PosterizeState {
file: File | null;
pdfJsDoc: pdfjsLib.PDFDocumentProxy | null;
pdfBytes: Uint8Array | null;
pageSnapshots: Record<number, ImageData>;
currentPage: number;
}

View File

@@ -0,0 +1,3 @@
export interface RemoveMetadataState {
file: File | null;
}

View File

@@ -0,0 +1,3 @@
export interface RemoveRestrictionsState {
file: File | null;
}

View File

@@ -0,0 +1,5 @@
export interface ReverseState {
file: File | null;
pdfBytes: ArrayBuffer | null;
totalPages: number;
}

View File

@@ -0,0 +1,6 @@
export interface RotateState {
file: File | null;
pdfBytes: ArrayBuffer | null;
totalPages: number;
pageRotations: Map<number, number>;
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument } from 'pdf-lib';
export interface SanitizePdfState {
file: File | null;
pdfDoc: PDFDocument | null;
}

View File

@@ -0,0 +1,5 @@
export interface SignPdfState {
file: File | null;
pdfBytes: ArrayBuffer | null;
signatureData: string | null;
}

View File

@@ -0,0 +1,18 @@
export interface GenerateTOCMessage {
type: 'generateTOC';
pdfBytes: ArrayBuffer;
title: string;
headerColor: string;
fontColor: string;
fontSize: number;
}
export interface TOCSuccessResponse {
type: 'success';
pdfBytes: ArrayBuffer;
}
export interface TOCErrorResponse {
type: 'error';
message: string;
}

View File

@@ -0,0 +1,6 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface TextColorState {
file: File | null;
pdfDoc: PDFLibDocument | null;
}

View File

@@ -0,0 +1,47 @@
import forge from 'node-forge';
export interface SignatureValidationResult {
signatureIndex: number;
isValid: boolean;
signerName: string;
signerOrg?: string;
signerEmail?: string;
issuer: string;
issuerOrg?: string;
signatureDate?: Date;
validFrom: Date;
validTo: Date;
isExpired: boolean;
isSelfSigned: boolean;
isTrusted: boolean;
algorithms: {
digest: string;
signature: string;
};
serialNumber: string;
reason?: string;
location?: string;
contactInfo?: string;
byteRange?: number[];
coverageStatus: 'full' | 'partial' | 'unknown';
errorMessage?: string;
}
export interface ExtractedSignature {
index: number;
contents: Uint8Array;
byteRange: number[];
reason?: string;
location?: string;
contactInfo?: string;
name?: string;
signingTime?: string;
}
export interface ValidateSignatureState {
pdfFile: File | null;
pdfBytes: Uint8Array | null;
results: SignatureValidationResult[];
trustedCertFile: File | null;
trustedCert: forge.pki.Certificate | null;
}

View File

@@ -0,0 +1,4 @@
export interface ViewMetadataState {
file: File | null;
metadata: Record<string, unknown>;
}

View File

@@ -306,19 +306,19 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
// Event Listeners
decrementBtn.addEventListener('click', (e) => {
e.stopPropagation();
let current = parseInt(angleInput.value) || 0;
const current = parseInt(angleInput.value) || 0;
updateRotation(current - 1);
});
incrementBtn.addEventListener('click', (e) => {
e.stopPropagation();
let current = parseInt(angleInput.value) || 0;
const current = parseInt(angleInput.value) || 0;
updateRotation(current + 1);
});
angleInput.addEventListener('change', (e) => {
e.stopPropagation();
let val = parseInt((e.target as HTMLInputElement).value) || 0;
const val = parseInt((e.target as HTMLInputElement).value) || 0;
updateRotation(val);
});
angleInput.addEventListener('click', (e) => e.stopPropagation());
@@ -331,7 +331,7 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
rotateBtn.addEventListener('click', (e) => {
e.stopPropagation();
let current = parseInt(angleInput.value) || 0;
const current = parseInt(angleInput.value) || 0;
updateRotation(current + 90);
});

View File

@@ -2,7 +2,7 @@
// This script applies the full-width preference from localStorage to page uploaders
export function initFullWidthMode() {
const savedFullWidth = localStorage.getItem('fullWidthMode') === 'true';
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
if (savedFullWidth) {
applyFullWidthMode(true);

View File

@@ -6,7 +6,8 @@
*/
import { WorkerBrowserConverter } from '@matbee/libreoffice-converter/browser';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const LIBREOFFICE_LOCAL_PATH = import.meta.env.BASE_URL + 'libreoffice-wasm/';
export interface LoadProgress {
phase: 'loading' | 'initializing' | 'converting' | 'complete' | 'ready';
@@ -26,8 +27,7 @@ export class LibreOfficeConverter {
private basePath: string;
constructor(basePath?: string) {
// Use CDN if available, otherwise use provided basePath or default local path
this.basePath = basePath || getWasmBaseUrl('libreoffice');
this.basePath = basePath || LIBREOFFICE_LOCAL_PATH;
}
async initialize(onProgress?: ProgressCallback): Promise<void> {

Some files were not shown because too many files have changed in this diff Show More