Merge branch 'main' of https://github.com/alam00000/bentopdf into add-spanish-translation

This commit is contained in:
Raul Gonzalez committed 2026-01-08 14:01:02 -06:00
commit cc891f2b0a
160 files changed
+84711 -56203

No files matched your search

+24 -9
View File
@@ -7,7 +7,8 @@ export const categories = [
href: import.meta.env.BASE_URL + 'pdf-multi-tool.html',
name: 'PDF Multi Tool',
icon: 'ph-pencil-ruler',
subtitle: 'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface.',
subtitle:
'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface.',
},
{
href: import.meta.env.BASE_URL + 'merge-pdf.html',
@@ -158,7 +159,8 @@ export const categories = [
href: import.meta.env.BASE_URL + 'form-filler.html',
name: 'PDF Form Filler',
icon: 'ph-pencil-line',
subtitle: 'Fill in forms directly in the browser. Also supports XFA forms.',
subtitle:
'Fill in forms directly in the browser. Also supports XFA forms.',
},
{
href: import.meta.env.BASE_URL + 'form-creator.html',
@@ -181,7 +183,8 @@ export const categories = [
href: import.meta.env.BASE_URL + 'image-to-pdf.html',
name: 'Images to PDF',
icon: 'ph-images',
subtitle: 'Convert JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP to PDF.',
subtitle:
'Convert JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP to PDF.',
},
{
href: import.meta.env.BASE_URL + 'jpg-to-pdf.html',
@@ -235,7 +238,8 @@ export const categories = [
href: import.meta.env.BASE_URL + 'markdown-to-pdf.html',
name: 'Markdown to PDF',
icon: 'ph-markdown-logo',
subtitle: 'Convert Markdown to PDF with live preview and syntax highlighting.',
subtitle:
'Convert Markdown to PDF with live preview and syntax highlighting.',
},
{
href: import.meta.env.BASE_URL + 'json-to-pdf.html',
@@ -367,7 +371,14 @@ export const categories = [
href: import.meta.env.BASE_URL + 'psd-to-pdf.html',
name: 'PSD to PDF',
icon: 'ph-image',
subtitle: 'Convert Adobe Photoshop (PSD) files to PDF. Multiple files supported.',
subtitle:
'Convert Adobe Photoshop (PSD) files to PDF. Multiple files supported.',
},
{
href: import.meta.env.BASE_URL + 'email-to-pdf.html',
name: 'Email to PDF',
icon: 'ph-envelope',
subtitle: 'Convert email files (EML, MSG) to PDF format.',
},
],
},
@@ -456,7 +467,8 @@ export const categories = [
href: import.meta.env.BASE_URL + 'prepare-pdf-for-ai.html',
name: 'Prepare PDF for AI',
icon: 'ph-sparkle',
subtitle: 'Extract PDF content as LlamaIndex JSON for RAG/LLM pipelines.',
subtitle:
'Extract PDF content as LlamaIndex JSON for RAG/LLM pipelines.',
},
{
href: import.meta.env.BASE_URL + 'pdf-to-text.html',
@@ -485,7 +497,8 @@ export const categories = [
href: import.meta.env.BASE_URL + 'alternate-merge.html',
name: 'Alternate & Mix Pages',
icon: 'ph-shuffle',
subtitle: 'Merge PDFs by alternating pages from each PDF. Preserves Bookmarks',
subtitle:
'Merge PDFs by alternating pages from each PDF. Preserves Bookmarks',
},
{
href: import.meta.env.BASE_URL + 'organize-pdf.html',
@@ -677,7 +690,8 @@ export const categories = [
href: import.meta.env.BASE_URL + 'rasterize-pdf.html',
name: 'Rasterize PDF',
icon: 'ph-image',
subtitle: 'Convert PDF to image-based PDF. Flatten layers and remove selectable text.',
subtitle:
'Convert PDF to image-based PDF. Flatten layers and remove selectable text.',
},
],
},
@@ -724,7 +738,8 @@ export const categories = [
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.',
subtitle:
'Add a cryptographic digital signature using X.509 certificates.',
},
{
href: import.meta.env.BASE_URL + 'validate-signature-pdf.html',
+131 -112
View File
@@ -3,156 +3,175 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';
// Supported languages
export const supportedLanguages = ['en', 'de', 'es', 'zh', 'vi', 'it'] as const;
export const supportedLanguages = [
'en',
'de',
'es',
'zh',
'vi',
'tr',
'id',
'it',
] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const languageNames: Record<SupportedLanguage, string> = {
en: 'English',
de: 'Deutsch',
es: 'Español',
zh: '中文',
vi: 'Tiếng Việt',
it: 'Italiano',
en: 'English',
de: 'Deutsch',
es: 'Español',
zh: '中文',
vi: 'Tiếng Việt',
tr: 'Türkçe',
id: 'Bahasa Indonesia',
it: 'Italiano',
};
export const getLanguageFromUrl = (): SupportedLanguage => {
const path = window.location.pathname;
const langMatch = path.match(/^\/(en|de|es|zh|vi|it)(?:\/|$)/);
if (langMatch && supportedLanguages.includes(langMatch[1] as SupportedLanguage)) {
return langMatch[1] as SupportedLanguage;
}
const storedLang = localStorage.getItem('i18nextLng');
if (storedLang && supportedLanguages.includes(storedLang as SupportedLanguage)) {
return storedLang as SupportedLanguage;
}
const path = window.location.pathname;
const langMatch = path.match(/^\/(en|de|es|zh|vi|tr|id|it)(?:\/|$)/);
if (
langMatch &&
supportedLanguages.includes(langMatch[1] as SupportedLanguage)
) {
return langMatch[1] as SupportedLanguage;
}
const storedLang = localStorage.getItem('i18nextLng');
if (
storedLang &&
supportedLanguages.includes(storedLang as SupportedLanguage)
) {
return storedLang as SupportedLanguage;
}
return 'en';
return 'en';
};
let initialized = false;
export const initI18n = async (): Promise<typeof i18next> => {
if (initialized) return i18next;
if (initialized) return i18next;
const currentLang = getLanguageFromUrl();
const currentLang = getLanguageFromUrl();
await i18next
.use(HttpBackend)
.use(LanguageDetector)
.init({
lng: currentLang,
fallbackLng: 'en',
supportedLngs: supportedLanguages as unknown as string[],
ns: ['common', 'tools'],
defaultNS: 'common',
backend: {
loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`,
},
detection: {
order: ['path', 'localStorage', 'navigator'],
lookupFromPathIndex: 0,
caches: ['localStorage'],
},
interpolation: {
escapeValue: false,
},
});
await i18next
.use(HttpBackend)
.use(LanguageDetector)
.init({
lng: currentLang,
fallbackLng: 'en',
supportedLngs: supportedLanguages as unknown as string[],
ns: ['common', 'tools'],
defaultNS: 'common',
backend: {
loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`,
},
detection: {
order: ['path', 'localStorage', 'navigator'],
lookupFromPathIndex: 0,
caches: ['localStorage'],
},
interpolation: {
escapeValue: false,
},
});
initialized = true;
return i18next;
initialized = true;
return i18next;
};
export const t = (key: string, options?: Record<string, unknown>): string => {
return i18next.t(key, options);
return i18next.t(key, options);
};
export const changeLanguage = (lang: SupportedLanguage): void => {
if (!supportedLanguages.includes(lang)) return;
if (!supportedLanguages.includes(lang)) return;
const currentPath = window.location.pathname;
const currentLang = getLanguageFromUrl();
const currentPath = window.location.pathname;
const currentLang = getLanguageFromUrl();
let newPath: string;
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}`;
}
let newPath: string;
if (currentPath.match(/^\/(en|de|zh|vi|tr|id|it)\//)) {
newPath = currentPath.replace(/^\/(en|de|zh|vi|tr|id|it)\//, `/${lang}/`);
} else if (currentPath.match(/^\/(en|de|zh|vi|tr|id|it)$/)) {
newPath = `/${lang}`;
} else {
newPath = `/${lang}${currentPath}`;
}
const newUrl = newPath + window.location.search + window.location.hash;
window.location.href = newUrl;
const newUrl = newPath + window.location.search + window.location.hash;
window.location.href = newUrl;
};
// Apply translations to all elements with data-i18n attribute
export const applyTranslations = (): void => {
document.querySelectorAll('[data-i18n]').forEach((element) => {
const key = element.getAttribute('data-i18n');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
element.textContent = translation;
}
}
});
document.querySelectorAll('[data-i18n]').forEach((element) => {
const key = element.getAttribute('data-i18n');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
element.textContent = translation;
}
}
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.getAttribute('data-i18n-placeholder');
if (key && element instanceof HTMLInputElement) {
const translation = t(key);
if (translation && translation !== key) {
element.placeholder = translation;
}
}
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.getAttribute('data-i18n-placeholder');
if (key && element instanceof HTMLInputElement) {
const translation = t(key);
if (translation && translation !== key) {
element.placeholder = translation;
}
}
});
document.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.getAttribute('data-i18n-title');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
(element as HTMLElement).title = translation;
}
}
});
document.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.getAttribute('data-i18n-title');
if (key) {
const translation = t(key);
if (translation && translation !== key) {
(element as HTMLElement).title = translation;
}
}
});
document.documentElement.lang = i18next.language;
document.documentElement.lang = i18next.language;
};
export const rewriteLinks = (): void => {
const currentLang = getLanguageFromUrl();
if (currentLang === 'en') return;
const currentLang = getLanguageFromUrl();
if (currentLang === 'en') return;
const links = document.querySelectorAll('a[href]');
links.forEach((link) => {
const href = link.getAttribute('href');
if (!href) return;
const links = document.querySelectorAll('a[href]');
links.forEach((link) => {
const href = link.getAttribute('href');
if (!href) return;
if (href.startsWith('http') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#') ||
href.startsWith('javascript:')) {
return;
}
if (
href.startsWith('http') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#') ||
href.startsWith('javascript:')
) {
return;
}
if (href.match(/^\/(en|de|zh|vi|it)\//)) {
return;
}
let newHref: string;
if (href.startsWith('/')) {
newHref = `/${currentLang}${href}`;
} else if (href.startsWith('./')) {
newHref = href.replace('./', `/${currentLang}/`);
} else if (href === '/' || href === '') {
newHref = `/${currentLang}/`;
} else {
newHref = `/${currentLang}/${href}`;
}
if (href.match(/^\/(en|de|zh|vi|tr|id|it)\//)) {
return;
}
let newHref: string;
if (href.startsWith('/')) {
newHref = `/${currentLang}${href}`;
} else if (href.startsWith('./')) {
newHref = href.replace('./', `/${currentLang}/`);
} else if (href === '/' || href === '') {
newHref = `/${currentLang}/`;
} else {
newHref = `/${currentLang}/${href}`;
}
link.setAttribute('href', newHref);
});
link.setAttribute('href', newHref);
});
};
export default i18next;
+14 -2
View File
@@ -1,3 +1,15 @@
export { initI18n, t, changeLanguage, applyTranslations, rewriteLinks, getLanguageFromUrl, supportedLanguages, languageNames } from './i18n';
export {
initI18n,
t,
changeLanguage,
applyTranslations,
rewriteLinks,
getLanguageFromUrl,
supportedLanguages,
languageNames,
} from './i18n';
export type { SupportedLanguage } from './i18n';
export { createLanguageSwitcher, injectLanguageSwitcher } from './language-switcher';
export {
createLanguageSwitcher,
injectLanguageSwitcher,
} from './language-switcher';
+268
View File
@@ -0,0 +1,268 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { parseEmailFile, renderEmailToHtml } from './email-to-pdf.js';
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
const EXTENSIONS = ['.eml', '.msg'];
const TOOL_NAME = 'Email';
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const fileDisplayArea = document.getElementById('file-display-area');
const fileControls = document.getElementById('file-controls');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
const convertOptions = document.getElementById('convert-options');
const updateUI = async () => {
if (!fileDisplayArea || !processBtn || !fileControls) return;
if (state.files.length > 0) {
fileDisplayArea.innerHTML = '';
for (let index = 0; index < state.files.length; index++) {
const file = state.files[index];
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.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 = () => {
state.files = state.files.filter((_, i) => i !== index);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
fileControls.classList.remove('hidden');
if (convertOptions) convertOptions.classList.remove('hidden');
(processBtn as HTMLButtonElement).disabled = false;
} else {
fileDisplayArea.innerHTML = '';
fileControls.classList.add('hidden');
if (convertOptions) convertOptions.classList.add('hidden');
(processBtn as HTMLButtonElement).disabled = true;
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
updateUI();
};
const convertToPdf = async () => {
try {
if (state.files.length === 0) {
showAlert(
'No Files',
`Please select at least one ${TOOL_NAME} file (.eml or .msg).`
);
return;
}
const pageSizeSelect = document.getElementById(
'page-size'
) as HTMLSelectElement;
const includeCcBccCheckbox = document.getElementById(
'include-cc-bcc'
) as HTMLInputElement;
const includeAttachmentsCheckbox = document.getElementById(
'include-attachments'
) as HTMLInputElement;
const pageSize =
(pageSizeSelect?.value as 'a4' | 'letter' | 'legal') || 'a4';
const includeCcBcc = includeCcBccCheckbox?.checked ?? true;
const includeAttachments = includeAttachmentsCheckbox?.checked ?? true;
showLoader('Loading PDF engine...');
const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
await pymupdf.load();
if (state.files.length === 1) {
const originalFile = state.files[0];
showLoader(`Parsing ${originalFile.name}...`);
const email = await parseEmailFile(originalFile);
showLoader('Generating PDF...');
const htmlContent = renderEmailToHtml(email, {
includeCcBcc,
includeAttachments,
pageSize,
});
const pdfBlob = await (pymupdf as any).htmlToPdf(htmlContent, {
pageSize,
margins: { top: 50, right: 50, bottom: 50, left: 50 },
attachments: email.attachments
.filter((a) => a.content)
.map((a) => ({
filename: a.filename,
content: a.content!,
})),
});
const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
downloadFile(pdfBlob, fileName);
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${originalFile.name} to PDF.`,
'success',
() => resetState()
);
} else {
showLoader('Converting emails...');
const JSZip = (await import('jszip')).default;
const zip = 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}...`
);
try {
const email = await parseEmailFile(file);
const htmlContent = renderEmailToHtml(email, {
includeCcBcc,
includeAttachments,
pageSize,
});
const pdfBlob = await (pymupdf as any).htmlToPdf(htmlContent, {
pageSize,
margins: { top: 50, right: 50, bottom: 50, left: 50 },
attachments: email.attachments
.filter((a) => a.content)
.map((a) => ({
filename: a.filename,
content: a.content!,
})),
});
const baseName = file.name.replace(/\.[^.]+$/, '');
const pdfBuffer = await pdfBlob.arrayBuffer();
zip.file(`${baseName}.pdf`, pdfBuffer);
} catch (e: any) {
console.error(`Failed to convert ${file.name}:`, e);
}
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'emails-converted.zip');
hideLoader();
showAlert(
'Conversion Complete',
`Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
'success',
() => resetState()
);
}
} catch (e: any) {
console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
hideLoader();
showAlert(
'Error',
`An error occurred during conversion. Error: ${e.message}`
);
}
};
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
state.files = [...state.files, ...Array.from(files)];
updateUI();
}
};
if (fileInput && dropZone) {
fileInput.addEventListener('change', (e) => {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const validFiles = Array.from(files).filter((f) => {
const name = f.name.toLowerCase();
return EXTENSIONS.some((ext) => name.endsWith(ext));
});
if (validFiles.length > 0) {
const dataTransfer = new DataTransfer();
validFiles.forEach((f) => dataTransfer.items.add(f));
handleFileSelect(dataTransfer.files);
}
}
});
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', () => {
resetState();
});
}
if (processBtn) {
processBtn.addEventListener('click', convertToPdf);
}
});
+393
View File
@@ -0,0 +1,393 @@
import PostalMime from 'postal-mime';
import MsgReader from '@kenjiuno/msgreader';
import { formatBytes, escapeHtml } from '../utils/helpers.js';
import type { EmailAttachment, ParsedEmail, EmailRenderOptions } from '@/types';
// Re-export types for convenience
export type { EmailAttachment, ParsedEmail, EmailRenderOptions };
/**
* Format email address without angle brackets for cleaner display
*/
function formatAddress(
name: string | undefined,
email: string | undefined
): string {
if (name && email) {
return `${name} (${email})`;
}
return email || name || '';
}
export async function parseEmlFile(file: File): Promise<ParsedEmail> {
const arrayBuffer = await file.arrayBuffer();
const parser = new PostalMime();
const email = await parser.parse(arrayBuffer);
const from =
formatAddress(email.from?.name, email.from?.address) || 'Unknown Sender';
const to = (email.to || [])
.map((addr) => formatAddress(addr.name, addr.address))
.filter(Boolean);
const cc = (email.cc || [])
.map((addr) => formatAddress(addr.name, addr.address))
.filter(Boolean);
const bcc = (email.bcc || [])
.map((addr) => formatAddress(addr.name, addr.address))
.filter(Boolean);
// Helper to map parsing result to EmailAttachment
const mapAttachment = (att: any): EmailAttachment => {
let content: Uint8Array | undefined;
let size = 0;
if (att.content) {
if (att.content instanceof ArrayBuffer) {
content = new Uint8Array(att.content);
size = content.byteLength;
} else if (att.content instanceof Uint8Array) {
content = att.content;
size = content.byteLength;
}
}
return {
filename: att.filename || 'unnamed',
size,
contentType: att.mimeType || 'application/octet-stream',
content,
contentId: att.contentId
? att.contentId.replace(/^<|>$/g, '')
: undefined,
};
};
const attachments: EmailAttachment[] = [
...(email.attachments || []).map(mapAttachment),
...((email as any).inline || []).map(mapAttachment),
];
// Preserve original date string from headers
let rawDateString = '';
if (email.headers) {
const dateHeader = email.headers.find(
(h) => h.key.toLowerCase() === 'date'
);
if (dateHeader) {
rawDateString = dateHeader.value as string;
}
}
if (!rawDateString && email.date) {
rawDateString = email.date; // fallback if header missing but parsed date exists as string?
}
let parsedDate: Date | null = null;
if (email.date) {
try {
parsedDate = new Date(email.date);
if (isNaN(parsedDate.getTime())) {
parsedDate = null;
}
} catch {
parsedDate = null;
}
}
return {
subject: email.subject || '(No Subject)',
from,
to,
cc,
bcc,
date: parsedDate,
rawDateString,
htmlBody: email.html || '',
textBody: email.text || '',
attachments,
};
}
export async function parseMsgFile(file: File): Promise<ParsedEmail> {
const arrayBuffer = await file.arrayBuffer();
const msgReader = new MsgReader(arrayBuffer);
const msgData = msgReader.getFileData();
const from =
formatAddress(msgData.senderName, msgData.senderEmail) || 'Unknown Sender';
const to: string[] = [];
const cc: string[] = [];
const bcc: string[] = [];
if (msgData.recipients) {
for (const recipient of msgData.recipients) {
const recipientStr = formatAddress(recipient.name, recipient.email);
if (!recipientStr) continue;
const recipType = String(recipient.recipType).toLowerCase();
if (recipType === 'cc' || recipType === '2') {
cc.push(recipientStr);
} else if (recipType === 'bcc' || recipType === '3') {
bcc.push(recipientStr);
} else {
to.push(recipientStr);
}
}
}
const attachments: EmailAttachment[] = (msgData.attachments || []).map(
(att: any) => ({
filename: att.fileName || att.name || 'unnamed',
size: att.content?.length || 0,
contentType: att.mimeType || 'application/octet-stream',
content: att.content ? new Uint8Array(att.content) : undefined,
contentId: att.pidContentId
? att.pidContentId.replace(/^<|>$/g, '')
: undefined,
})
);
let date: Date | null = null;
let rawDateString = '';
if (msgData.messageDeliveryTime) {
rawDateString = msgData.messageDeliveryTime;
date = new Date(msgData.messageDeliveryTime);
} else if (msgData.clientSubmitTime) {
rawDateString = msgData.clientSubmitTime;
date = new Date(msgData.clientSubmitTime);
}
return {
subject: msgData.subject || '(No Subject)',
from,
to,
cc,
bcc,
date,
rawDateString,
htmlBody: msgData.bodyHtml || '',
textBody: msgData.body || '',
attachments,
};
}
/**
* Formats a raw RFC 2822 date string into a nicer human-readable format,
* while preserving the original timezone and time.
* Example input: "Sun, 8 Jan 2017 20:37:44 +0200"
* Example output: "Sunday, January 8, 2017 at 8:37 PM (+0200)"
*/
function formatRawDate(raw: string): string {
try {
// Regex to parse RFC 2822 date parts: Day, DD Mon YYYY HH:MM:SS Timezone
const match = raw.match(
/([A-Za-z]{3}),\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+([+-]\d{4})/
);
if (match) {
const [
,
dayAbbr,
dom,
monthAbbr,
year,
hoursStr,
minsStr,
secsStr,
timezone,
] = match;
// Map abbreviations to full names
const days: Record<string, string> = {
Sun: 'Sunday',
Mon: 'Monday',
Tue: 'Tuesday',
Wed: 'Wednesday',
Thu: 'Thursday',
Fri: 'Friday',
Sat: 'Saturday',
};
const months: Record<string, string> = {
Jan: 'January',
Feb: 'February',
Mar: 'March',
Apr: 'April',
May: 'May',
Jun: 'June',
Jul: 'July',
Aug: 'August',
Sep: 'September',
Oct: 'October',
Nov: 'November',
Dec: 'December',
};
const fullDay = days[dayAbbr] || dayAbbr;
const fullMonth = months[monthAbbr] || monthAbbr;
// Convert to 12-hour format manually
let hours = parseInt(hoursStr, 10);
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
// Format timezone: +0200 -> UTC+02:00
const tzSign = timezone.substring(0, 1);
const tzHours = timezone.substring(1, 3);
const tzMins = timezone.substring(3, 5);
const formattedTz = `UTC${tzSign}${tzHours}:${tzMins}`;
return `${fullDay}, ${fullMonth} ${dom}, ${year} at ${hours}:${minsStr} ${ampm} (${formattedTz})`;
}
} catch (e) {
// Fallback to raw string if parsing fails
}
return raw;
}
/**
* Replace CID references in HTML with base64 data URIs
*/
function processInlineImages(
html: string,
attachments: EmailAttachment[]
): string {
if (!html) return html;
// Create a map of contentIds to attachments
const cidMap = new Map<string, EmailAttachment>();
attachments.forEach((att) => {
if (att.contentId) {
cidMap.set(att.contentId, att);
}
});
// Replace src="cid:..."
return html.replace(/src=["']cid:([^"']+)["']/g, (match, cid) => {
const att = cidMap.get(cid);
if (att && att.content) {
// Convert Uint8Array to base64
let binary = '';
const len = att.content.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(att.content[i]);
}
const base64 =
typeof btoa === 'function'
? btoa(binary)
: Buffer.from(binary, 'binary').toString('base64');
return `src="data:${att.contentType};base64,${base64}"`;
}
return match; // Keep original if not found
});
}
export function renderEmailToHtml(
email: ParsedEmail,
options: EmailRenderOptions = {}
): string {
const { includeCcBcc = true, includeAttachments = true } = options;
let processedHtml = '';
if (email.htmlBody) {
processedHtml = processInlineImages(email.htmlBody, email.attachments);
} else {
processedHtml = `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">${escapeHtml(email.textBody)}</pre>`;
}
// Format date in a human-readable way
let dateStr = 'Unknown Date';
if (email.rawDateString) {
dateStr = formatRawDate(email.rawDateString);
} else if (email.date && !isNaN(email.date.getTime())) {
dateStr = email.date.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
const attachmentHtml =
includeAttachments && email.attachments.length > 0
? `
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #cccccc;">
<p style="margin: 0 0 8px 0; font-size: 11px; color: #666; font-weight: 600;">Attachments (${email.attachments.length})</p>
<ul style="margin: 0; padding-left: 20px; font-size: 10px; color: #555;">
${email.attachments
.map(
(att) =>
`<li style="margin-bottom: 5px;">${escapeHtml(att.filename)} <span style="color: #999;">(${formatBytes(att.size)})</span></li>`
)
.join('')}
</ul>
</div>
`
: '';
// Build CC/BCC rows
let ccBccHtml = '';
if (includeCcBcc) {
if (email.cc.length > 0) {
ccBccHtml += `
<div style="margin-bottom: 8px;">
<span style="font-weight: 600; color: #666; margin-right: 8px;">CC:</span>
<span style="color: #333;">${escapeHtml(email.cc.join(', '))}</span>
</div>`;
}
if (email.bcc.length > 0) {
ccBccHtml += `
<div style="margin-bottom: 8px;">
<span style="font-weight: 600; color: #666; margin-right: 8px;">BCC:</span>
<span style="color: #333;">${escapeHtml(email.bcc.join(', '))}</span>
</div>`;
}
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; background: #fff;">
<div style="border-bottom: 2px solid #999999; padding-bottom: 20px; margin-bottom: 30px;">
<h1 style="font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px 0;">${escapeHtml(email.subject)}</h1>
<div style="font-size: 12px; color: #555;">
<div style="margin-bottom: 8px;">
<span style="font-weight: 600; color: #666; margin-right: 8px;">From:</span>
<span style="color: #333;">${escapeHtml(email.from)}</span>
</div>
<div style="margin-bottom: 8px;">
<span style="font-weight: 600; color: #666; margin-right: 8px;">To:</span>
<span style="color: #333;">${escapeHtml(email.to.join(', ') || 'Unknown')}</span>
</div>
${ccBccHtml}
<div style="margin-bottom: 8px;">
<span style="font-weight: 600; color: #666; margin-right: 8px;">Date:</span>
<span style="color: #333;">${escapeHtml(dateStr)}</span>
</div>
</div>
</div>
<div style="font-size: 12px; color: #333;">
${processedHtml}
</div>
${attachmentHtml}
</body>
</html>`;
}
export async function parseEmailFile(file: File): Promise<ParsedEmail> {
const ext = file.name.toLowerCase().split('.').pop();
if (ext === 'eml') {
return parseEmlFile(file);
} else if (ext === 'msg') {
return parseMsgFile(file);
} else {
throw new Error(`Unsupported file type: .${ext}`);
}
}
+160 -77
View File
@@ -1,14 +1,20 @@
import { categories } from './config/tools.js';
import { dom, switchView, hideAlert, showLoader, hideLoader, showAlert } from './ui.js';
import { state, resetState } from './state.js';
import { dom, switchView, hideAlert } from './ui.js';
import { ShortcutsManager } from './logic/shortcuts.js';
import { createIcons, icons } from 'lucide';
import '@phosphor-icons/web/regular';
import * as pdfjsLib from 'pdfjs-dist';
import '../css/styles.css';
import { formatShortcutDisplay, formatStars } from './utils/helpers.js';
import { APP_VERSION, injectVersion } from '../version.js';
import { initI18n, applyTranslations, rewriteLinks, injectLanguageSwitcher, createLanguageSwitcher, t } from './i18n/index.js';
import { APP_VERSION } from '../version.js';
import {
initI18n,
applyTranslations,
rewriteLinks,
injectLanguageSwitcher,
createLanguageSwitcher,
t,
} from './i18n/index.js';
import { startBackgroundPreload } from './utils/wasm-preloader.js';
const init = async () => {
@@ -16,7 +22,10 @@ const init = async () => {
injectLanguageSwitcher();
applyTranslations();
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
if (__SIMPLE_MODE__) {
const hideBrandingSections = () => {
const nav = document.querySelector('nav');
@@ -46,7 +55,9 @@ const init = async () => {
heroSection.style.display = 'none';
}
const githubLink = document.querySelector('a[href*="github.com/alam00000/bentopdf"]');
const githubLink = document.querySelector(
'a[href*="github.com/alam00000/bentopdf"]'
);
if (githubLink) {
(githubLink as HTMLElement).style.display = 'none';
}
@@ -81,7 +92,9 @@ const init = async () => {
}
// Hide "Used by companies" section
const usedBySection = document.querySelector('.hide-section') as HTMLElement;
const usedBySection = document.querySelector(
'.hide-section'
) as HTMLElement;
if (usedBySection) {
usedBySection.style.display = 'none';
}
@@ -114,7 +127,9 @@ const init = async () => {
`;
document.body.appendChild(simpleFooter);
const langContainer = simpleFooter.querySelector('#simple-mode-lang-switcher');
const langContainer = simpleFooter.querySelector(
'#simple-mode-lang-switcher'
);
if (langContainer) {
const switcher = createLanguageSwitcher();
const dropdown = switcher.querySelector('div[role="menu"]');
@@ -167,13 +182,14 @@ const init = async () => {
if (shortcutSettingsBtn) shortcutSettingsBtn.style.display = 'none';
} else {
if (keyboardShortcutBtn) {
keyboardShortcutBtn.textContent = navigator.userAgent.toUpperCase().includes('MAC')
keyboardShortcutBtn.textContent = navigator.userAgent
.toUpperCase()
.includes('MAC')
? '⌘ + K'
: 'Ctrl + K';
}
}
const categoryTranslationKeys: Record<string, string> = {
'Popular Tools': 'tools:categories.popularTools',
'Edit & Annotate': 'tools:categories.editAnnotate',
@@ -254,6 +270,7 @@ const init = async () => {
'Flatten PDF': 'tools:flattenPdf',
'Remove Metadata': 'tools:removeMetadata',
'Change Permissions': 'tools:changePermissions',
'Email to PDF': 'tools:emailToPdf',
};
// Homepage-only tool grid rendering (not used on individual tool pages)
@@ -265,7 +282,8 @@ const init = async () => {
categoryGroup.className = 'category-group col-span-full';
const title = document.createElement('h2');
title.className = 'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white';
title.className =
'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white';
const categoryKey = categoryTranslationKeys[category.name];
title.textContent = categoryKey ? t(categoryKey) : category.name;
@@ -307,7 +325,9 @@ const init = async () => {
if (tool.subtitle) {
const toolSubtitle = document.createElement('p');
toolSubtitle.className = 'text-xs text-gray-400 mt-1 px-2';
toolSubtitle.textContent = toolKey ? t(`${toolKey}.subtitle`) : tool.subtitle;
toolSubtitle.textContent = toolKey
? t(`${toolKey}.subtitle`)
: tool.subtitle;
toolCard.appendChild(toolSubtitle);
}
@@ -323,7 +343,8 @@ const init = async () => {
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';
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', () => {
@@ -357,13 +378,22 @@ const init = async () => {
const toolCards = Array.from(group.querySelectorAll('.tool-card'));
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 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 toolId = toolHref.split('/').pop()?.replace('.html', '') || toolName;
const toolId =
toolHref.split('/').pop()?.replace('.html', '') || toolName;
const isMatch = toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
const isMatch =
toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
const isDuplicate = seenToolIds.has(toolId);
if (isMatch && !isDuplicate) {
@@ -431,31 +461,29 @@ const init = async () => {
// Start background WASM preloading on all pages
startBackgroundPreload();
const githubStarsElements = [
document.getElementById('github-stars-desktop'),
document.getElementById('github-stars-mobile')
document.getElementById('github-stars-mobile'),
];
if (githubStarsElements.some(el => el) && !__SIMPLE_MODE__) {
if (githubStarsElements.some((el) => el) && !__SIMPLE_MODE__) {
fetch('https://api.github.com/repos/alam00000/bentopdf')
.then((response) => response.json())
.then((data) => {
if (data.stargazers_count !== undefined) {
const formattedStars = formatStars(data.stargazers_count);
githubStarsElements.forEach(el => {
githubStarsElements.forEach((el) => {
if (el) el.textContent = formattedStars;
});
}
})
.catch(() => {
githubStarsElements.forEach(el => {
githubStarsElements.forEach((el) => {
if (el) el.textContent = '-';
});
});
}
// Initialize Shortcuts System
ShortcutsManager.init();
@@ -463,9 +491,13 @@ const init = async () => {
const shortcutsTabBtn = document.getElementById('shortcuts-tab-btn');
const preferencesTabBtn = document.getElementById('preferences-tab-btn');
const shortcutsTabContent = document.getElementById('shortcuts-tab-content');
const preferencesTabContent = document.getElementById('preferences-tab-content');
const preferencesTabContent = document.getElementById(
'preferences-tab-content'
);
const shortcutsTabFooter = document.getElementById('shortcuts-tab-footer');
const preferencesTabFooter = document.getElementById('preferences-tab-footer');
const preferencesTabFooter = document.getElementById(
'preferences-tab-footer'
);
const resetShortcutsBtn = document.getElementById('reset-shortcuts-btn');
if (shortcutsTabBtn && preferencesTabBtn) {
@@ -495,7 +527,9 @@ const init = async () => {
}
// Full-width toggle functionality
const fullWidthToggle = document.getElementById('full-width-toggle') as HTMLInputElement;
const fullWidthToggle = document.getElementById(
'full-width-toggle'
) as HTMLInputElement;
const toolInterface = document.getElementById('tool-interface');
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
@@ -520,7 +554,10 @@ const init = async () => {
uploader.classList.remove('max-w-2xl', 'max-w-5xl');
} else {
// Restore original max-width (most are max-w-2xl, add-stamps is max-w-5xl)
if (!uploader.classList.contains('max-w-2xl') && !uploader.classList.contains('max-w-5xl')) {
if (
!uploader.classList.contains('max-w-2xl') &&
!uploader.classList.contains('max-w-5xl')
) {
uploader.classList.add('max-w-2xl');
}
}
@@ -641,26 +678,36 @@ const init = async () => {
}
// Reserved shortcuts that commonly conflict with browser/OS functions
const RESERVED_SHORTCUTS: Record<string, { mac?: string; windows?: string }> = {
'mod+w': { mac: 'Closes tab', windows: 'Closes tab' },
'mod+t': { mac: 'Opens new tab', windows: 'Opens new tab' },
'mod+n': { mac: 'Opens new window', windows: 'Opens new window' },
'mod+shift+n': { mac: 'Opens incognito window', windows: 'Opens incognito window' },
'mod+q': { mac: 'Quits application (cannot be overridden)' },
'mod+m': { mac: 'Minimizes window' },
'mod+h': { mac: 'Hides window' },
'mod+r': { mac: 'Reloads page', windows: 'Reloads page' },
'mod+shift+r': { mac: 'Hard reloads page', windows: 'Hard reloads page' },
'mod+l': { mac: 'Focuses address bar', windows: 'Focuses address bar' },
'mod+d': { mac: 'Bookmarks page', windows: 'Bookmarks page' },
'mod+shift+t': { mac: 'Reopens closed tab', windows: 'Reopens closed tab' },
'mod+shift+w': { mac: 'Closes window', windows: 'Closes window' },
'mod+tab': { mac: 'Switches tabs', windows: 'Switches apps' },
'alt+f4': { windows: 'Closes window' },
'ctrl+tab': { mac: 'Switches tabs', windows: 'Switches tabs' },
};
const RESERVED_SHORTCUTS: Record<string, { mac?: string; windows?: string }> =
{
'mod+w': { mac: 'Closes tab', windows: 'Closes tab' },
'mod+t': { mac: 'Opens new tab', windows: 'Opens new tab' },
'mod+n': { mac: 'Opens new window', windows: 'Opens new window' },
'mod+shift+n': {
mac: 'Opens incognito window',
windows: 'Opens incognito window',
},
'mod+q': { mac: 'Quits application (cannot be overridden)' },
'mod+m': { mac: 'Minimizes window' },
'mod+h': { mac: 'Hides window' },
'mod+r': { mac: 'Reloads page', windows: 'Reloads page' },
'mod+shift+r': { mac: 'Hard reloads page', windows: 'Hard reloads page' },
'mod+l': { mac: 'Focuses address bar', windows: 'Focuses address bar' },
'mod+d': { mac: 'Bookmarks page', windows: 'Bookmarks page' },
'mod+shift+t': {
mac: 'Reopens closed tab',
windows: 'Reopens closed tab',
},
'mod+shift+w': { mac: 'Closes window', windows: 'Closes window' },
'mod+tab': { mac: 'Switches tabs', windows: 'Switches apps' },
'alt+f4': { windows: 'Closes window' },
'ctrl+tab': { mac: 'Switches tabs', windows: 'Switches tabs' },
};
function getReservedShortcutWarning(combo: string, isMac: boolean): string | null {
function getReservedShortcutWarning(
combo: string,
isMac: boolean
): string | null {
const reserved = RESERVED_SHORTCUTS[combo];
if (!reserved) return null;
@@ -670,9 +717,19 @@ const init = async () => {
return description;
}
function showWarningModal(title: string, message: string, confirmMode: boolean = true): Promise<boolean> {
function showWarningModal(
title: string,
message: string,
confirmMode: boolean = true
): Promise<boolean> {
return new Promise((resolve) => {
if (!dom.warningModal || !dom.warningTitle || !dom.warningMessage || !dom.warningCancelBtn || !dom.warningConfirmBtn) {
if (
!dom.warningModal ||
!dom.warningTitle ||
!dom.warningMessage ||
!dom.warningCancelBtn ||
!dom.warningConfirmBtn
) {
resolve(confirmMode ? confirm(message) : (alert(message), true));
return;
}
@@ -711,15 +768,19 @@ const init = async () => {
dom.warningCancelBtn.addEventListener('click', handleCancel);
// Close on backdrop click
dom.warningModal.addEventListener('click', (e) => {
if (e.target === dom.warningModal) {
if (confirmMode) {
handleCancel();
} else {
handleConfirm();
dom.warningModal.addEventListener(
'click',
(e) => {
if (e.target === dom.warningModal) {
if (confirmMode) {
handleCancel();
} else {
handleConfirm();
}
}
}
}, { once: true });
},
{ once: true }
);
});
}
@@ -738,14 +799,15 @@ const init = async () => {
const allShortcuts = ShortcutsManager.getAllShortcuts();
const isMac = navigator.userAgent.toUpperCase().includes('MAC');
const allTools = categories.flatMap(c => c.tools);
const allTools = categories.flatMap((c) => c.tools);
categories.forEach(category => {
categories.forEach((category) => {
const section = document.createElement('div');
section.className = 'category-section mb-6 last:mb-0';
const header = document.createElement('h3');
header.className = 'text-gray-400 text-xs font-bold uppercase tracking-wider mb-3 pl-1';
header.className =
'text-gray-400 text-xs font-bold uppercase tracking-wider mb-3 pl-1';
// Translate category name
const categoryKey = categoryTranslationKeys[category.name];
header.textContent = categoryKey ? t(categoryKey) : category.name;
@@ -757,13 +819,14 @@ const init = async () => {
let hasTools = false;
category.tools.forEach(tool => {
category.tools.forEach((tool) => {
hasTools = true;
const toolId = getToolId(tool);
const currentShortcut = allShortcuts.get(toolId) || '';
const item = document.createElement('div');
item.className = 'shortcut-item flex items-center justify-between p-3 bg-gray-900 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors';
item.className =
'shortcut-item flex items-center justify-between p-3 bg-gray-900 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors';
const left = document.createElement('div');
left.className = 'flex items-center gap-3';
@@ -784,13 +847,15 @@ const init = async () => {
const input = document.createElement('input');
input.type = 'text';
input.className = 'shortcut-input w-32 bg-gray-800 border border-gray-600 text-white text-center text-sm rounded px-2 py-1 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all';
input.className =
'shortcut-input w-32 bg-gray-800 border border-gray-600 text-white text-center text-sm rounded px-2 py-1 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all';
input.placeholder = t('settings.clickToSet');
input.value = formatShortcutDisplay(currentShortcut, isMac);
input.readOnly = true;
const clearBtn = document.createElement('button');
clearBtn.className = 'absolute -right-2 -top-2 bg-gray-700 hover:bg-red-600 text-white rounded-full p-0.5 hidden group-hover:block shadow-sm';
clearBtn.className =
'absolute -right-2 -top-2 bg-gray-700 hover:bg-red-600 text-white rounded-full p-0.5 hidden group-hover:block shadow-sm';
clearBtn.innerHTML = '<i data-lucide="x" class="w-3 h-3"></i>';
if (currentShortcut) {
right.classList.add('group');
@@ -839,7 +904,10 @@ const init = async () => {
// Ignore dead keys (used for accented characters on Mac with Option key)
if (isDeadKey) {
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
return;
}
@@ -855,22 +923,31 @@ const init = async () => {
const existingToolId = ShortcutsManager.findToolByShortcut(combo);
if (existingToolId && existingToolId !== toolId) {
const existingTool = allTools.find(t => getToolId(t) === existingToolId);
const existingTool = allTools.find(
(t) => getToolId(t) === existingToolId
);
const existingToolName = existingTool?.name || existingToolId;
const displayCombo = formatShortcutDisplay(combo, isMac);
const existingToolKey = existingTool ? toolTranslationKeys[existingTool.name] : null;
const translatedToolName = existingToolKey ? t(`${existingToolKey}.name`) : existingToolName;
const existingToolKey = existingTool
? toolTranslationKeys[existingTool.name]
: null;
const translatedToolName = existingToolKey
? t(`${existingToolKey}.name`)
: existingToolName;
await showWarningModal(
t('settings.warnings.alreadyInUse'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
`<em>"${translatedToolName}"</em><br><br>` +
t('settings.warnings.chooseDifferent'),
`<em>"${translatedToolName}"</em><br><br>` +
t('settings.warnings.chooseDifferent'),
false
);
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
input.classList.remove('border-indigo-500', 'text-indigo-400');
input.blur();
return;
@@ -882,14 +959,17 @@ const init = async () => {
const shouldProceed = await showWarningModal(
t('settings.warnings.reserved'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
`"<em>${reservedWarning}</em>"<br><br>` +
`${t('settings.warnings.unreliable')}<br><br>` +
t('settings.warnings.useAnyway')
`"<em>${reservedWarning}</em>"<br><br>` +
`${t('settings.warnings.unreliable')}<br><br>` +
t('settings.warnings.useAnyway')
);
if (!shouldProceed) {
// Revert display
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
input.classList.remove('border-indigo-500', 'text-indigo-400');
input.blur();
return;
@@ -916,7 +996,10 @@ const init = async () => {
};
input.onblur = () => {
input.value = formatShortcutDisplay(ShortcutsManager.getShortcut(toolId) || '', isMac);
input.value = formatShortcutDisplay(
ShortcutsManager.getShortcut(toolId) || '',
isMac
);
input.classList.remove('border-indigo-500', 'text-indigo-400');
};
@@ -955,7 +1038,7 @@ const init = async () => {
scrollToTopBtn.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'instant'
behavior: 'instant',
});
});
}
+26
View File
@@ -0,0 +1,26 @@
export interface EmailAttachment {
filename: string;
size: number;
contentType: string;
content?: Uint8Array;
contentId?: string;
}
export interface ParsedEmail {
subject: string;
from: string;
to: string[];
cc: string[];
bcc: string[];
date: Date | null;
rawDateString: string;
htmlBody: string;
textBody: string;
attachments: EmailAttachment[];
}
export interface EmailRenderOptions {
includeCcBcc?: boolean;
includeAttachments?: boolean;
pageSize?: 'a4' | 'letter' | 'legal';
}
+48 -48
View File
@@ -1,48 +1,48 @@
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';
export * from './ocr-type.ts';
export * from './form-creator-type.ts';
export * from './digital-sign-type.ts';
export * from './attachment-type.ts';
export * from './edit-attachments-type.ts';
export * from './edit-metadata-type.ts';
export * from './divide-pages-type.ts';
export * from './alternate-merge-page-type.ts';
export * from './add-blank-page-type.ts';
export * from './compare-pdfs-type.ts';
export * from './fix-page-size-type.ts';
export * from './view-metadata-type.ts';
export * from './header-footer-type.ts';
export * from './encrypt-pdf-type.ts';
export * from './flatten-pdf-type.ts';
export * from './crop-pdf-type.ts';
export * from './background-color-type.ts';
export * from './posterize-type.ts';
export * from './decrypt-pdf-type.ts';
export * from './combine-single-page-type.ts';
export * from './change-permissions-type.ts';
export * from './validate-signature-type.ts';
export * from './remove-restrictions-type.ts';
export * from './page-dimensions-type.ts';
export * from './extract-attachments-type.ts';
export * from './pdf-multi-tool-type.ts';
export * from './ocr-pdf-type.ts';
export * from './delete-pages-type.ts';
export * from './invert-colors-type.ts';
export * from './table-of-contents-type.ts';
export * from './organize-pdf-type.ts';
export * from './merge-pdf-type.ts';
export * from './extract-images-type.ts';
export * from './extract-pages-type.ts';
export * from './pdf-layers-type.ts';
export * from './sanitize-pdf-type.ts';
export * from './reverse-pages-type.ts';
export * from './text-color-type.ts';
export * from './n-up-pdf-type.ts';
export * from './linearize-pdf-type.ts';
export * from './remove-metadata-type.ts';
export * from './rotate-pdf-type.ts';
export * from './pdf-booklet-type.ts';
export * from './page-numbers-type.ts';
export * from './pdf-to-zip-type.ts';
export * from './sign-pdf-type.ts';
export * from './add-watermark-type.ts';
export * from './email-to-pdf-type.ts';
+40 -17
View File
@@ -2,8 +2,7 @@ import createModule from '@neslinesli93/qpdf-wasm';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons } from 'lucide';
import { state, resetState } from '../state.js';
import * as pdfjsLib from 'pdfjs-dist'
import * as pdfjsLib from 'pdfjs-dist';
const STANDARD_SIZES = {
A4: { width: 595.28, height: 841.89 },
@@ -50,14 +49,14 @@ export function convertPoints(points: any, unit: any) {
// Convert hex color to RGB
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
}
: { r: 0, g: 0, b: 0 }
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
}
: { r: 0, g: 0, b: 0 };
}
export const formatBytes = (bytes: any, decimals = 1) => {
@@ -89,7 +88,10 @@ export const readFileAsArrayBuffer = (file: any) => {
});
};
export function parsePageRanges(rangeString: string, totalPages: number): number[] {
export function parsePageRanges(
rangeString: string,
totalPages: number
): number[] {
if (!rangeString || rangeString.trim() === '') {
return Array.from({ length: totalPages }, (_, i) => i);
}
@@ -128,11 +130,9 @@ export function parsePageRanges(rangeString: string, totalPages: number): number
}
}
return Array.from(indices).sort((a, b) => a - b);
}
/**
* Formats an ISO 8601 date string (e.g., "2008-02-21T17:15:56-08:00")
* into a localized, human-readable string.
@@ -198,7 +198,7 @@ export function formatStars(num: number) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
};
}
/**
* Truncates a filename to a maximum length, adding ellipsis if needed.
@@ -207,14 +207,18 @@ export function formatStars(num: number) {
* @param maxLength - Maximum length (default: 30)
* @returns Truncated filename with ellipsis if needed
*/
export function truncateFilename(filename: string, maxLength: number = 25): string {
export function truncateFilename(
filename: string,
maxLength: number = 25
): string {
if (filename.length <= maxLength) {
return filename;
}
const lastDotIndex = filename.lastIndexOf('.');
const extension = lastDotIndex !== -1 ? filename.substring(lastDotIndex) : '';
const nameWithoutExt = lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename;
const nameWithoutExt =
lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename;
const availableLength = maxLength - extension.length - 3; // 3 for '...'
@@ -225,7 +229,10 @@ export function truncateFilename(filename: string, maxLength: number = 25): stri
return nameWithoutExt.substring(0, availableLength) + '...' + extension;
}
export function formatShortcutDisplay(shortcut: string, isMac: boolean): string {
export function formatShortcutDisplay(
shortcut: string,
isMac: boolean
): string {
if (!shortcut) return '';
return shortcut
.replace('mod', isMac ? '⌘' : 'Ctrl')
@@ -233,7 +240,7 @@ export function formatShortcutDisplay(shortcut: string, isMac: boolean): string
.replace('alt', isMac ? '⌥' : 'Alt')
.replace('shift', 'Shift')
.split('+')
.map(k => k.charAt(0).toUpperCase() + k.slice(1))
.map((k) => k.charAt(0).toUpperCase() + k.slice(1))
.join(isMac ? '' : '+');
}
@@ -263,7 +270,7 @@ export function resetAndReloadTool(preResetCallback?: () => void) {
export function getPDFDocument(src: any) {
let params = src;
// Handle different input types similar to how getDocument handles them,
// Handle different input types similar to how getDocument handles them,
// but we ensure we have an object to attach wasmUrl to.
if (typeof src === 'string') {
params = { url: src };
@@ -283,3 +290,19 @@ export function getPDFDocument(src: any) {
wasmUrl: import.meta.env.BASE_URL + 'pdfjs-viewer/wasm/',
});
}
/**
* Escape HTML special characters to prevent XSS
* @param text - The text to escape
* @returns The escaped text
*/
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
+3 -1
View File
@@ -31,7 +31,9 @@ if (__SIMPLE_MODE__) {
`;
document.body.appendChild(simpleFooter);
const langContainer = simpleFooter.querySelector('#simple-mode-lang-switcher');
const langContainer = simpleFooter.querySelector(
'#simple-mode-lang-switcher'
);
if (langContainer) {
const switcher = createLanguageSwitcher();
const dropdown = switcher.querySelector('div[role="menu"]');