mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-10 17:14:34 +01:00
Merge branch 'main' into feature/improve-custom-rotate
This commit is contained in:
369 files changed
+18347
-5298
No files matched your search
@@ -65,11 +65,9 @@ worker.onmessage = function (e) {
|
||||
if (data.status === 'success' && data.modifiedPDF !== undefined) {
|
||||
hideLoader();
|
||||
|
||||
const originalName =
|
||||
pageState.file?.name.replace(/\.pdf$/i, '') || 'document';
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(data.modifiedPDF)], { type: 'application/pdf' }),
|
||||
`${originalName}_with_attachments.pdf`
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -178,11 +178,9 @@ async function addBlankPages() {
|
||||
}
|
||||
|
||||
const newPdfBytes = await newPdf.save();
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_blank-pages-added.pdf`
|
||||
pageState.file.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -502,7 +502,7 @@ async function addPageLabels() {
|
||||
|
||||
downloadFile(
|
||||
new Blob([outputBytes], { type: 'application/pdf' }),
|
||||
'page-labels-added.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
translate('common.success', 'Success'),
|
||||
|
||||
@@ -1010,7 +1010,7 @@ async function applyWatermark() {
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
|
||||
'watermarked.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert('Success', 'Watermark added successfully!', 'success');
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -234,7 +234,7 @@ async function processAllPages(): Promise<void> {
|
||||
const resultBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
|
||||
'color-adjusted.pdf'
|
||||
files[0]?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -5,11 +5,9 @@ import { createIcons, icons } from 'lucide';
|
||||
import Sortable from 'sortablejs';
|
||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||
import { makeUniqueFileKey } from '../utils/deduplicate-filename.js';
|
||||
import {
|
||||
showWasmRequiredDialog,
|
||||
WasmProvider,
|
||||
} from '../utils/wasm-provider.js';
|
||||
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
|
||||
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
|
||||
import { interleavePdfs } from '../utils/alternate-merge.js';
|
||||
|
||||
const pageState: AlternateMergeState = {
|
||||
files: [],
|
||||
@@ -17,10 +15,6 @@ const pageState: AlternateMergeState = {
|
||||
pdfDocs: new Map(),
|
||||
};
|
||||
|
||||
const alternateMergeWorker = new Worker(
|
||||
import.meta.env.BASE_URL + 'workers/alternate-merge.worker.js'
|
||||
);
|
||||
|
||||
function resetState() {
|
||||
pageState.files = [];
|
||||
pageState.pdfBytes.clear();
|
||||
@@ -180,47 +174,33 @@ async function mixPages() {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
command: 'interleave',
|
||||
files: filesToMerge,
|
||||
cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
|
||||
};
|
||||
|
||||
alternateMergeWorker.postMessage(
|
||||
message,
|
||||
filesToMerge.map(function (f) {
|
||||
return f.data;
|
||||
})
|
||||
);
|
||||
|
||||
alternateMergeWorker.onmessage = function (e: MessageEvent) {
|
||||
hideLoader();
|
||||
if (e.data.status === 'success') {
|
||||
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
|
||||
downloadFile(blob, 'alternated-mixed.pdf');
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDFs have been mixed successfully!',
|
||||
'success',
|
||||
function () {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.error('Worker interleave error:', e.data.message);
|
||||
showAlert('Error', e.data.message || 'Failed to interleave PDFs.');
|
||||
}
|
||||
};
|
||||
|
||||
alternateMergeWorker.onerror = function (e) {
|
||||
hideLoader();
|
||||
console.error('Worker error:', e);
|
||||
showAlert('Error', 'An unexpected error occurred in the merge worker.');
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Alternate Merge error:', e);
|
||||
showAlert('Error', 'An error occurred while mixing the PDFs.');
|
||||
const retainCheckbox = document.getElementById(
|
||||
'retain-page-labels'
|
||||
) as HTMLInputElement | null;
|
||||
const mergedBytes = await interleavePdfs(filesToMerge, {
|
||||
retainPageLabels: retainCheckbox?.checked ?? false,
|
||||
});
|
||||
hideLoader();
|
||||
const blob = new Blob([new Uint8Array(mergedBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, 'alternated-mixed.pdf');
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDFs have been mixed successfully!',
|
||||
'success',
|
||||
function () {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
hideLoader();
|
||||
console.error('Alternate Merge error:', e);
|
||||
const message =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'An error occurred while mixing the PDFs.';
|
||||
showAlert('Error', message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ async function changeBackgroundColor() {
|
||||
const newPdfBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'background-changed.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -516,7 +516,7 @@ async function applyBatesNumbers() {
|
||||
fileCounter++;
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
results.push({
|
||||
name: `bates_${entry.file.name}`,
|
||||
name: entry.file.name,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -232,22 +232,22 @@ function showInputModal(
|
||||
if (field.type === 'text') {
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||
<input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
|
||||
placeholder="${field.placeholder || ''}" />
|
||||
placeholder="${escapeHTML(field.placeholder || '')}" />
|
||||
</div>
|
||||
`;
|
||||
} else if (field.type === 'select') {
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900">
|
||||
${field.options
|
||||
.map(
|
||||
(opt) => `
|
||||
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
|
||||
${opt.label}
|
||||
<option value="${escapeHTML(opt.value)}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
|
||||
${escapeHTML(opt.label)}
|
||||
</option>
|
||||
`
|
||||
)
|
||||
@@ -261,7 +261,7 @@ placeholder="${field.placeholder || ''}" />
|
||||
defaultValues.destX !== null && defaultValues.destX !== undefined;
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-1 text-xs">
|
||||
@@ -313,7 +313,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
|
||||
} else if (field.type === 'preview') {
|
||||
return `
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
|
||||
<div id="modal-preview" class="style-preview bg-gray-50">
|
||||
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
|
||||
</div>
|
||||
@@ -326,7 +326,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
|
||||
<div class="mb-6">
|
||||
${fieldsHTML}
|
||||
</div>
|
||||
@@ -830,7 +830,7 @@ function showConfirmModal(message: string): Promise<boolean> {
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
|
||||
<p class="text-gray-600 mb-6">${message}</p>
|
||||
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
|
||||
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
|
||||
@@ -882,8 +882,8 @@ function showAlertModal(title: string, message: string): Promise<boolean> {
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
|
||||
<p class="text-gray-600 mb-6">${message}</p>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
|
||||
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
|
||||
<div class="flex justify-end">
|
||||
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
|
||||
</div>
|
||||
@@ -2306,7 +2306,7 @@ downloadBtn?.addEventListener('click', async () => {
|
||||
const blob = new Blob([new Uint8Array(pdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `${originalFileName}-bookmarked.pdf`);
|
||||
downloadFile(blob, `${originalFileName}.pdf`);
|
||||
|
||||
await showAlertModal('Success', 'PDF saved successfully!');
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ async function changePermissions() {
|
||||
const blob = new Blob([new Uint8Array(outputFile)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `permissions-changed-${pageState.file.name}`);
|
||||
downloadFile(blob, pageState.file.name);
|
||||
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
|
||||
|
||||
@@ -243,11 +243,9 @@ async function combineToSinglePage() {
|
||||
}
|
||||
|
||||
const newPdfBytes = await newDoc.save();
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_combined.pdf`
|
||||
pageState.file.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -1183,11 +1183,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
pageState.viewMode === 'overlay'
|
||||
? Number.parseFloat(opacitySlider?.value || '0.5')
|
||||
: undefined,
|
||||
includeChange:
|
||||
pageState.viewMode === 'overlay'
|
||||
? (change) =>
|
||||
shouldIncludeChange(change, { includeSearch: false })
|
||||
: undefined,
|
||||
includeChange: (change) =>
|
||||
shouldIncludeChange(change, { includeSearch: false }),
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -465,10 +465,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const savingsPercent =
|
||||
savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
|
||||
|
||||
downloadFile(
|
||||
resultBlob,
|
||||
originalFile.name.replace(/\.pdf$/i, '') + '_compressed.pdf'
|
||||
);
|
||||
downloadFile(resultBlob, originalFile.name);
|
||||
|
||||
hideLoader();
|
||||
|
||||
@@ -523,8 +520,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
totalCompressedSize += resultBytes.length;
|
||||
const baseName = file.name.replace(/\.pdf$/i, '');
|
||||
zip.file(`${baseName}_compressed.pdf`, resultBytes);
|
||||
zip.file(file.name, resultBytes);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
|
||||
@@ -288,10 +288,9 @@ async function performCrop() {
|
||||
finalPdfBytes = await performMetadataCrop(finalCropData);
|
||||
}
|
||||
|
||||
const fileName = isDestructive ? 'flattened_crop.pdf' : 'standard_crop.pdf';
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(finalPdfBytes)], { type: 'application/pdf' }),
|
||||
fileName
|
||||
cropperState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -137,7 +137,7 @@ async function decryptPdf() {
|
||||
const blob = new Blob([decryptedBytes.slice().buffer], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `unlocked-${file.name}`);
|
||||
downloadFile(blob, file.name);
|
||||
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
showAlert(
|
||||
@@ -168,7 +168,7 @@ async function decryptPdf() {
|
||||
password
|
||||
);
|
||||
|
||||
zip.file(`unlocked-${file.name}`, decryptedBytes, { binary: true });
|
||||
zip.file(file.name, decryptedBytes, { binary: true });
|
||||
successCount++;
|
||||
} catch (fileError: unknown) {
|
||||
errorCount++;
|
||||
|
||||
@@ -267,12 +267,11 @@ async function deletePages() {
|
||||
new Uint8Array(srcBytes),
|
||||
deleteState.pagesToDelete
|
||||
);
|
||||
const baseName = deleteState.file?.name.replace('.pdf', '') || 'document';
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
`${baseName}_pages_removed.pdf`
|
||||
deleteState.file?.name || 'document.pdf'
|
||||
);
|
||||
|
||||
hideLoader();
|
||||
|
||||
@@ -71,22 +71,41 @@ function updateFileDisplay(): void {
|
||||
fileControls.classList.remove('hidden');
|
||||
deskewOptions.classList.remove('hidden');
|
||||
|
||||
fileDisplayArea.innerHTML = selectedFiles
|
||||
.map(
|
||||
(file, index) => `
|
||||
<div class="flex items-center justify-between bg-gray-700 p-3 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="file-text" class="w-5 h-5 text-indigo-400"></i>
|
||||
<span class="text-gray-200 truncate max-w-xs">${file.name}</span>
|
||||
<span class="text-gray-500 text-sm">(${(file.size / 1024).toFixed(1)} KB)</span>
|
||||
</div>
|
||||
<button class="remove-file text-gray-400 hover:text-red-400" data-index="${index}">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
fileDisplayArea.textContent = '';
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const row = document.createElement('div');
|
||||
row.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'flex items-center gap-3';
|
||||
|
||||
const fileIcon = document.createElement('i');
|
||||
fileIcon.setAttribute('data-lucide', 'file-text');
|
||||
fileIcon.className = 'w-5 h-5 text-indigo-400';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'text-gray-200 truncate max-w-xs';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'text-gray-500 text-sm';
|
||||
sizeSpan.textContent = `(${(file.size / 1024).toFixed(1)} KB)`;
|
||||
|
||||
info.append(fileIcon, nameSpan, sizeSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'remove-file text-gray-400 hover:text-red-400';
|
||||
removeBtn.dataset.index = String(index);
|
||||
|
||||
const removeIcon = document.createElement('i');
|
||||
removeIcon.setAttribute('data-lucide', 'x');
|
||||
removeIcon.className = 'w-5 h-5';
|
||||
removeBtn.appendChild(removeIcon);
|
||||
|
||||
row.append(info, removeBtn);
|
||||
fileDisplayArea.appendChild(row);
|
||||
});
|
||||
|
||||
createIcons({ icons });
|
||||
|
||||
@@ -171,8 +190,7 @@ async function processDeskew(): Promise<void> {
|
||||
|
||||
displayResults(result);
|
||||
|
||||
const filename = file.name.replace('.pdf', '_deskewed.pdf');
|
||||
downloadFile(resultPdf, filename);
|
||||
downloadFile(resultPdf, file.name);
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
|
||||
@@ -190,8 +190,8 @@ function initializePage(): void {
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
showAlert(
|
||||
'Invalid Image',
|
||||
'Please select a PNG, JPG, or WebP image.'
|
||||
t('tools:digitalSignPdf.invalidImageTitle'),
|
||||
t('tools:digitalSignPdf.invalidImageMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -203,8 +203,15 @@ function initializePage(): void {
|
||||
|
||||
if (sigImageThumb && sigImagePreview) {
|
||||
const url = URL.createObjectURL(file);
|
||||
sigImageThumb.src = url;
|
||||
sigImagePreview.classList.remove('hidden');
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'blob:') {
|
||||
sigImageThumb.src = parsed.href;
|
||||
sigImagePreview.classList.remove('hidden');
|
||||
}
|
||||
} catch {
|
||||
console.warn('Invalid blob URL for signature preview');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -242,7 +249,10 @@ async function handlePdfFile(file: File): Promise<void> {
|
||||
file.type !== 'application/pdf' &&
|
||||
!file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
showAlert('Invalid File', 'Please select a PDF file.');
|
||||
showAlert(
|
||||
t('tools:digitalSignPdf.invalidFileTitle'),
|
||||
t('tools:digitalSignPdf.invalidFileMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,7 +317,7 @@ async function updatePdfDisplay(): Promise<void> {
|
||||
state.pdfFile = result.file;
|
||||
state.pdfBytes = new Uint8Array(result.bytes);
|
||||
nameSpan.textContent = result.file.name;
|
||||
metaSpan.textContent = `${formatBytes(result.file.size)} • ${result.pdf.numPages} pages`;
|
||||
metaSpan.textContent = `${formatBytes(result.file.size)} • ${t('tools:digitalSignPdf.pageCount', { count: result.pdf.numPages })}`;
|
||||
result.pdf.destroy();
|
||||
}
|
||||
}
|
||||
@@ -366,8 +376,8 @@ async function handleCertFile(file: File): Promise<void> {
|
||||
|
||||
if (!hasValidExtension) {
|
||||
showAlert(
|
||||
'Invalid Certificate',
|
||||
'Please select a .pfx, .p12, or .pem certificate file.'
|
||||
t('tools:digitalSignPdf.invalidCertificateTitle'),
|
||||
t('tools:digitalSignPdf.invalidCertificateMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -386,7 +396,7 @@ async function handleCertFile(file: File): Promise<void> {
|
||||
|
||||
if (isEncrypted) {
|
||||
showPasswordSection();
|
||||
updatePasswordLabel('Private Key Password');
|
||||
updatePasswordLabel(t('tools:digitalSignPdf.privateKeyPassword'));
|
||||
} else {
|
||||
state.certData = parseCombinedPem(pemContent);
|
||||
updateCertInfo();
|
||||
@@ -394,8 +404,7 @@ async function handleCertFile(file: File): Promise<void> {
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML =
|
||||
'Certificate loaded <i data-lucide="check" class="inline w-4 h-4"></i>';
|
||||
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateLoaded')} <i data-lucide="check" class="inline w-4 h-4"></i>`;
|
||||
createIcons({ icons });
|
||||
certStatus.className = 'text-xs text-green-400';
|
||||
}
|
||||
@@ -403,13 +412,13 @@ async function handleCertFile(file: File): Promise<void> {
|
||||
} catch {
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.textContent = 'Failed to parse PEM file';
|
||||
certStatus.textContent = t('tools:digitalSignPdf.failedParsePem');
|
||||
certStatus.className = 'text-xs text-red-400';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showPasswordSection();
|
||||
updatePasswordLabel('Certificate Password');
|
||||
updatePasswordLabel(t('tools:digitalSignPdf.certPassword'));
|
||||
}
|
||||
|
||||
hideSignatureOptions();
|
||||
@@ -437,7 +446,7 @@ function updateCertDisplay(): void {
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.id = 'cert-status';
|
||||
metaSpan.textContent = 'Enter password to unlock';
|
||||
metaSpan.textContent = t('tools:digitalSignPdf.enterPasswordToUnlock');
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
@@ -550,8 +559,7 @@ async function handlePasswordInput(): Promise<void> {
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML =
|
||||
'Certificate unlocked <i data-lucide="check-circle" class="inline w-4 h-4"></i>';
|
||||
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateUnlocked')} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
|
||||
createIcons({ icons });
|
||||
certStatus.className = 'text-xs text-green-400';
|
||||
}
|
||||
@@ -565,10 +573,10 @@ async function handlePasswordInput(): Promise<void> {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Invalid password or certificate';
|
||||
: t('tools:digitalSignPdf.invalidPasswordOrCertificate');
|
||||
certStatus.textContent = errorMessage.includes('password')
|
||||
? 'Incorrect password'
|
||||
: 'Failed to parse certificate';
|
||||
? t('tools:digitalSignPdf.incorrectPassword')
|
||||
: t('tools:digitalSignPdf.failedParseCertificate');
|
||||
certStatus.className = 'text-xs text-red-400';
|
||||
}
|
||||
}
|
||||
@@ -616,8 +624,8 @@ function updateProcessButton(): void {
|
||||
async function processSignature(): Promise<void> {
|
||||
if (!state.pdfBytes || !state.certData) {
|
||||
showAlert(
|
||||
'Missing Data',
|
||||
'Please upload both a PDF and a valid certificate.'
|
||||
t('tools:digitalSignPdf.missingDataTitle'),
|
||||
t('tools:digitalSignPdf.missingDataMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -699,7 +707,10 @@ async function processSignature(): Promise<void> {
|
||||
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}`;
|
||||
sigText = t('tools:digitalSignPdf.defaultSignatureText', {
|
||||
signer: certInfo.subject,
|
||||
date,
|
||||
});
|
||||
}
|
||||
|
||||
let finalHeight = sigHeight;
|
||||
@@ -728,7 +739,7 @@ async function processSignature(): Promise<void> {
|
||||
};
|
||||
}
|
||||
|
||||
showLoader('Applying digital signature...');
|
||||
showLoader(t('tools:digitalSignPdf.applyingSignature'));
|
||||
|
||||
try {
|
||||
const signedPdfBytes = await signPdf(state.pdfBytes, state.certData, {
|
||||
@@ -739,15 +750,12 @@ async function processSignature(): Promise<void> {
|
||||
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);
|
||||
downloadFile(blob, state.pdfFile?.name ?? 'document.pdf');
|
||||
|
||||
hideLoader();
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF signed successfully! The signature can be verified in any PDF reader.',
|
||||
t('common.success'),
|
||||
t('tools:digitalSignPdf.successMessage'),
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
@@ -757,7 +765,7 @@ async function processSignature(): Promise<void> {
|
||||
hideLoader();
|
||||
console.error('Signing error:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
error instanceof Error ? error.message : t('common.unknownError');
|
||||
|
||||
// Check if this is a CORS/network error from certificate chain fetching
|
||||
if (
|
||||
@@ -766,11 +774,14 @@ async function processSignature(): Promise<void> {
|
||||
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.'
|
||||
t('tools:digitalSignPdf.signingFailedTitle'),
|
||||
t('tools:digitalSignPdf.chainFetchFailed')
|
||||
);
|
||||
} else {
|
||||
showAlert('Signing Failed', `Failed to sign PDF: ${errorMessage}`);
|
||||
showAlert(
|
||||
t('tools:digitalSignPdf.signingFailedTitle'),
|
||||
t('tools:digitalSignPdf.failedToSignPdf', { message: errorMessage })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,21 @@ async function generateProxySignature(
|
||||
.join('');
|
||||
}
|
||||
|
||||
function resolveProxyBase(): string {
|
||||
if (
|
||||
CORS_PROXY_URL.startsWith('http://') ||
|
||||
CORS_PROXY_URL.startsWith('https://')
|
||||
) {
|
||||
return CORS_PROXY_URL;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return new URL(CORS_PROXY_URL, window.location.origin).toString();
|
||||
}
|
||||
return CORS_PROXY_URL;
|
||||
}
|
||||
|
||||
async function buildCorsProxyUrl(url: string): Promise<string> {
|
||||
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
||||
let proxyUrl = `${resolveProxyBase()}?url=${encodeURIComponent(url)}`;
|
||||
|
||||
if (!CORS_PROXY_SECRET) {
|
||||
return proxyUrl;
|
||||
@@ -196,6 +209,9 @@ function createCorsAwareFetch(): {
|
||||
? input.toString()
|
||||
: input.url;
|
||||
|
||||
const isAlreadyProxied =
|
||||
CORS_PROXY_URL.length > 0 && url.startsWith(CORS_PROXY_URL);
|
||||
|
||||
const isExternalCertificateUrl =
|
||||
(url.includes('.crt') ||
|
||||
url.includes('.cer') ||
|
||||
@@ -221,6 +237,7 @@ function createCorsAwareFetch(): {
|
||||
url.includes('RFC3161');
|
||||
|
||||
const shouldProxy =
|
||||
!isAlreadyProxied &&
|
||||
(isExternalCertificateUrl || isTsaRequest) &&
|
||||
!url.startsWith(window.location.origin);
|
||||
|
||||
@@ -332,6 +349,19 @@ export async function timestampPdf(
|
||||
pdfBytes: Uint8Array,
|
||||
tsaUrl: string
|
||||
): Promise<Uint8Array> {
|
||||
const pageIsHttps =
|
||||
typeof window !== 'undefined' && window.location?.protocol === 'https:';
|
||||
const tsaIsHttp = /^http:\/\//i.test(tsaUrl);
|
||||
|
||||
if (pageIsHttps && tsaIsHttp && !CORS_PROXY_URL) {
|
||||
throw new Error(
|
||||
`This TSA endpoint uses HTTP (${tsaUrl}). The browser blocks insecure ` +
|
||||
`requests from this HTTPS page. Either choose a TSA with an HTTPS ` +
|
||||
`endpoint or configure VITE_CORS_PROXY_URL at build time so the ` +
|
||||
`request can be relayed through your proxy.`
|
||||
);
|
||||
}
|
||||
|
||||
let effectiveUrl = tsaUrl;
|
||||
|
||||
if (CORS_PROXY_URL) {
|
||||
|
||||
@@ -177,11 +177,9 @@ async function dividePages() {
|
||||
}
|
||||
|
||||
const newPdfBytes = await newPdfDoc.save();
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_divided.pdf`
|
||||
pageState.file.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -222,7 +222,7 @@ export async function processAndSave() {
|
||||
const newPdfBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'organized.pdf'
|
||||
state.files[0]?.name || 'document.pdf'
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Save error:', e);
|
||||
|
||||
@@ -60,11 +60,9 @@ worker.onmessage = function (e) {
|
||||
} else if (data.status === 'success' && data.modifiedPDF !== undefined) {
|
||||
hideLoader();
|
||||
|
||||
const originalName =
|
||||
pageState.file?.name.replace(/\.pdf$/i, '') || 'document';
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(data.modifiedPDF)], { type: 'application/pdf' }),
|
||||
`${originalName}_edited.pdf`
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -351,11 +351,9 @@ async function saveMetadata() {
|
||||
});
|
||||
|
||||
const newPdfBytes = await pageState.pdfDoc.save();
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_metadata-edited.pdf`
|
||||
pageState.file.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { DocManagerPlugin } from '@/types';
|
||||
let viewerInstance: EmbedPdfContainer | null = null;
|
||||
let docManagerPlugin: DocManagerPlugin | null = null;
|
||||
let isViewerInitialized = false;
|
||||
let currentFileName = 'document.pdf';
|
||||
const fileEntryMap = new Map<string, HTMLElement>();
|
||||
|
||||
function resetViewer() {
|
||||
@@ -128,6 +129,7 @@ async function handleFiles(files: FileList) {
|
||||
|
||||
if (!isViewerInitialized) {
|
||||
const firstFile = decryptedFiles[0];
|
||||
currentFileName = firstFile.name;
|
||||
const firstBuffer = await firstFile.arrayBuffer();
|
||||
|
||||
pdfContainer.textContent = '';
|
||||
@@ -217,7 +219,7 @@ async function handleFiles(files: FileList) {
|
||||
const exportPlugin = registry.getPlugin('export').provides();
|
||||
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
downloadFile(blob, 'edited-document.pdf');
|
||||
downloadFile(blob, currentFileName);
|
||||
} catch (err) {
|
||||
console.error('Error downloading PDF:', err);
|
||||
showAlert('Error', 'Failed to download the edited PDF.');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PostalMime from 'postal-mime';
|
||||
import MsgReader from '@kenjiuno/msgreader';
|
||||
import MsgReaderDefault from '@kenjiuno/msgreader';
|
||||
import {
|
||||
formatBytes,
|
||||
escapeHtml,
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
} from '../utils/helpers.js';
|
||||
import type { EmailAttachment, ParsedEmail, EmailRenderOptions } from '@/types';
|
||||
|
||||
type MsgReaderCtor = typeof MsgReaderDefault;
|
||||
const MsgReader: MsgReaderCtor =
|
||||
(MsgReaderDefault as unknown as { default?: MsgReaderCtor }).default ??
|
||||
MsgReaderDefault;
|
||||
|
||||
export type { EmailAttachment, ParsedEmail, EmailRenderOptions };
|
||||
|
||||
function formatAddress(
|
||||
|
||||
@@ -173,7 +173,7 @@ async function encryptPdf() {
|
||||
const blob = new Blob([new Uint8Array(outputFile)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `encrypted-${pageState.file.name}`);
|
||||
downloadFile(blob, pageState.file.name);
|
||||
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ async function fixPageSize() {
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'standardized.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -174,10 +174,7 @@ async function flattenPdf() {
|
||||
}
|
||||
|
||||
const flattenedBytes = await pdfDoc.save();
|
||||
const zipEntryName = deduplicateFileName(
|
||||
`flattened_${file.name}`,
|
||||
usedNames
|
||||
);
|
||||
const zipEntryName = deduplicateFileName(file.name, usedNames);
|
||||
zip.file(zipEntryName, flattenedBytes);
|
||||
processedCount++;
|
||||
} catch (e) {
|
||||
|
||||
@@ -124,8 +124,7 @@ async function processFiles() {
|
||||
if (loaderText) loaderText.textContent = msg;
|
||||
});
|
||||
|
||||
const baseName = file.name.replace(/\.pdf$/i, '');
|
||||
downloadFile(resultBlob, `${baseName}_outlined.pdf`);
|
||||
downloadFile(resultBlob, file.name);
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
} else {
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
@@ -143,11 +142,7 @@ async function processFiles() {
|
||||
try {
|
||||
const resultBlob = await convertFileToOutlines(file, () => {});
|
||||
const arrayBuffer = await resultBlob.arrayBuffer();
|
||||
const baseName = file.name.replace(/\.pdf$/i, '');
|
||||
const zipEntryName = deduplicateFileName(
|
||||
`${baseName}_outlined.pdf`,
|
||||
usedNames
|
||||
);
|
||||
const zipEntryName = deduplicateFileName(file.name, usedNames);
|
||||
zip.file(zipEntryName, arrayBuffer);
|
||||
processedCount++;
|
||||
} catch (e) {
|
||||
|
||||
+95
-222
@@ -18,17 +18,10 @@ type LucideWindow = Window & {
|
||||
createIcons(): void;
|
||||
};
|
||||
};
|
||||
type PdfViewerApplicationLike = {
|
||||
pdfViewer?: {
|
||||
pagesCount: number;
|
||||
};
|
||||
};
|
||||
type PdfViewerWindow = Window & {
|
||||
PDFViewerApplication?: PdfViewerApplicationLike;
|
||||
};
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||
import { downloadFile, hexToRgb } from '../utils/helpers.js';
|
||||
import { downloadFile, escapeHtml, hexToRgb } from '../utils/helpers.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
@@ -65,10 +58,9 @@ let pages: PageData[] = [];
|
||||
let currentPageIndex = 0;
|
||||
let uploadedPdfDoc: PDFDocument | null = null;
|
||||
let uploadedPdfjsDoc: PDFDocumentProxy | null = null;
|
||||
let uploadedFileName: string | null = null;
|
||||
let pageSize: { width: number; height: number } = { width: 612, height: 792 };
|
||||
let currentScale = 1.333;
|
||||
let pdfViewerOffset = { x: 0, y: 0 };
|
||||
let pdfViewerScale = 1.333;
|
||||
|
||||
let resizing = false;
|
||||
let resizeField: FormField | null = null;
|
||||
@@ -729,7 +721,7 @@ function renderField(field: FormField): void {
|
||||
field,
|
||||
'#ffffff'
|
||||
);
|
||||
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${field.dateFormat || 'mm/dd/yyyy'}</span></div>`;
|
||||
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${escapeHtml(field.dateFormat || 'mm/dd/yyyy')}</span></div>`;
|
||||
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
||||
} else if (field.type === 'image') {
|
||||
contentEl.className =
|
||||
@@ -738,7 +730,7 @@ function renderField(field: FormField): void {
|
||||
field,
|
||||
'#f3f4f6'
|
||||
);
|
||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
|
||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${escapeHtml(field.label || 'Click to Upload Image')}</span></div>`;
|
||||
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
||||
} else if (field.type === 'barcode') {
|
||||
contentEl.className = 'w-full h-full flex items-center justify-center';
|
||||
@@ -763,7 +755,8 @@ function renderField(field: FormField): void {
|
||||
contentEl.appendChild(img);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to render barcode preview for field "${field.name}":`,
|
||||
'Failed to render barcode preview for field:',
|
||||
String(field.name).replace(/[\r\n]+/g, ' '),
|
||||
error
|
||||
);
|
||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Invalid data</span></div>`;
|
||||
@@ -1114,7 +1107,7 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Value</label>
|
||||
<input type="text" id="propValue" value="${field.defaultValue}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propValue" value="${escapeHtml(field.defaultValue)}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label>
|
||||
@@ -1160,11 +1153,11 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label>
|
||||
<input type="text" id="propGroupName" value="${field.groupName}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propGroupName" value="${escapeHtml(field.groupName)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label>
|
||||
<input type="text" id="propExportValue" value="${field.exportValue}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propExportValue" value="${escapeHtml(field.exportValue)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
|
||||
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
|
||||
@@ -1177,13 +1170,13 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label>
|
||||
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${field.options?.join('\n')}</textarea>
|
||||
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${escapeHtml(field.options?.join('\n') ?? '')}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label>
|
||||
<select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">None</option>
|
||||
${field.options?.map((opt) => `<option value="${opt}" ${field.defaultValue === opt ? 'selected' : ''}>${opt}</option>`).join('')}
|
||||
${field.options?.map((opt) => `<option value="${escapeHtml(opt)}" ${field.defaultValue === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 italic mt-2">
|
||||
@@ -1194,7 +1187,7 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Label</label>
|
||||
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Action</label>
|
||||
@@ -1209,11 +1202,11 @@ function showProperties(field: FormField): void {
|
||||
</div>
|
||||
<div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">URL</label>
|
||||
<input type="text" id="propActionUrl" value="${field.actionUrl || ''}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propActionUrl" value="${escapeHtml(field.actionUrl || '')}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label>
|
||||
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${field.jsScript || ''}</textarea>
|
||||
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${escapeHtml(field.jsScript || '')}</textarea>
|
||||
</div>
|
||||
<div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
|
||||
<div class="mb-2">
|
||||
@@ -1224,7 +1217,7 @@ function showProperties(field: FormField): void {
|
||||
.filter((f) => f.id !== field.id)
|
||||
.map(
|
||||
(f) =>
|
||||
`<option value="${f.name}" ${field.targetFieldName === f.name ? 'selected' : ''}>${f.name} (${f.type})</option>`
|
||||
`<option value="${escapeHtml(f.name)}" ${field.targetFieldName === f.name ? 'selected' : ''}>${escapeHtml(f.name)} (${escapeHtml(f.type)})</option>`
|
||||
)
|
||||
.join('')}
|
||||
</select>
|
||||
@@ -1290,7 +1283,7 @@ function showProperties(field: FormField): void {
|
||||
</div>
|
||||
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label>
|
||||
<input type="text" id="propCustomFormat" value="${isCustom ? field.dateFormat : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propCustomFormat" value="${isCustom ? escapeHtml(field.dateFormat ?? '') : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="mt-3 p-2 bg-gray-700 rounded">
|
||||
<span class="text-xs text-gray-400">Example of current format:</span>
|
||||
@@ -1307,7 +1300,7 @@ function showProperties(field: FormField): void {
|
||||
specificProps = `
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label>
|
||||
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 italic mt-2">
|
||||
Clicking this field in the PDF will open a file picker to upload an image.
|
||||
@@ -1329,17 +1322,17 @@ function showProperties(field: FormField): void {
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
|
||||
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propBarcodeValue" value="${escapeHtml(field.barcodeValue || '')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
propertiesPanel.innerHTML = `
|
||||
const propertiesHtml = `
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label>
|
||||
<input type="text" id="propName" value="${field.name}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propName" value="${escapeHtml(field.name)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<div id="nameError" class="hidden text-red-400 text-xs mt-1"></div>
|
||||
</div>
|
||||
${
|
||||
@@ -1352,7 +1345,10 @@ function showProperties(field: FormField): void {
|
||||
<select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">-- Select existing group --</option>
|
||||
${Array.from(existingRadioGroups)
|
||||
.map((name) => `<option value="${name}">${name}</option>`)
|
||||
.map(
|
||||
(name) =>
|
||||
`<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
|
||||
)
|
||||
.join('')}
|
||||
${Array.from(
|
||||
new Set(
|
||||
@@ -1363,7 +1359,7 @@ function showProperties(field: FormField): void {
|
||||
)
|
||||
.map((name) =>
|
||||
!existingRadioGroups.has(name)
|
||||
? `<option value="${name}">${name}</option>`
|
||||
? `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
|
||||
: ''
|
||||
)
|
||||
.join('')}
|
||||
@@ -1376,7 +1372,7 @@ function showProperties(field: FormField): void {
|
||||
${specificProps}
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label>
|
||||
<input type="text" id="propTooltip" value="${field.tooltip}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<input type="text" id="propTooltip" value="${escapeHtml(field.tooltip)}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
|
||||
@@ -1404,6 +1400,10 @@ function showProperties(field: FormField): void {
|
||||
</div>
|
||||
`;
|
||||
|
||||
propertiesPanel.innerHTML = DOMPurify.sanitize(propertiesHtml, {
|
||||
ADD_ATTR: ['target'],
|
||||
});
|
||||
|
||||
// Common listeners
|
||||
const propName = document.getElementById('propName') as HTMLInputElement;
|
||||
const nameError = document.getElementById('nameError') as HTMLDivElement;
|
||||
@@ -1793,7 +1793,7 @@ function showProperties(field: FormField): void {
|
||||
field.options
|
||||
?.map(
|
||||
(opt) =>
|
||||
`<option value="${opt}" ${currentVal === opt ? 'selected' : ''}>${opt}</option>`
|
||||
`<option value="${escapeHtml(opt)}" ${currentVal === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
@@ -2227,23 +2227,11 @@ downloadBtn.addEventListener('click', async () => {
|
||||
const pdfPage = pdfDoc.getPage(field.pageIndex);
|
||||
const { height: pageHeight } = pdfPage.getSize();
|
||||
|
||||
const scaleX = 1 / pdfViewerScale;
|
||||
const scaleY = 1 / pdfViewerScale;
|
||||
|
||||
const adjustedX = field.x - pdfViewerOffset.x;
|
||||
const adjustedY = field.y - pdfViewerOffset.y;
|
||||
|
||||
const x = adjustedX * scaleX;
|
||||
const y = pageHeight - adjustedY * scaleY - field.height * scaleY;
|
||||
const width = field.width * scaleX;
|
||||
const height = field.height * scaleY;
|
||||
|
||||
console.log(`Field "${field.name}":`, {
|
||||
screenPos: { x: field.x, y: field.y },
|
||||
adjustedPos: { x: adjustedX, y: adjustedY },
|
||||
pdfPos: { x, y, width, height },
|
||||
metrics: { offset: pdfViewerOffset, scale: pdfViewerScale },
|
||||
});
|
||||
const x = field.x / currentScale;
|
||||
const y =
|
||||
pageHeight - field.y / currentScale - field.height / currentScale;
|
||||
const width = field.width / currentScale;
|
||||
const height = field.height / currentScale;
|
||||
|
||||
if (field.type === 'text') {
|
||||
const textField = form.createTextField(field.name);
|
||||
@@ -2338,11 +2326,17 @@ downloadBtn.addEventListener('click', async () => {
|
||||
if (existingField) {
|
||||
radioGroup = existingField as PDFRadioGroup;
|
||||
radioGroups.set(groupName, radioGroup);
|
||||
console.log(`Using existing radio group from PDF: ${groupName}`);
|
||||
console.log(
|
||||
'Using existing radio group from PDF:',
|
||||
String(groupName).replace(/[\r\n]+/g, ' ')
|
||||
);
|
||||
} else {
|
||||
radioGroup = form.createRadioGroup(groupName);
|
||||
radioGroups.set(groupName, radioGroup);
|
||||
console.log(`Created new radio group: ${groupName}`);
|
||||
console.log(
|
||||
'Created new radio group:',
|
||||
String(groupName).replace(/[\r\n]+/g, ' ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2499,7 +2493,12 @@ downloadBtn.addEventListener('click', async () => {
|
||||
JS: field.jsScript,
|
||||
});
|
||||
} else if (field.action === 'showHide' && field.targetFieldName) {
|
||||
const target = field.targetFieldName;
|
||||
const target = field.targetFieldName
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\0/g, '\\0');
|
||||
let script: string;
|
||||
|
||||
if (field.visibilityAction === 'show') {
|
||||
@@ -2703,7 +2702,8 @@ downloadBtn.addEventListener('click', async () => {
|
||||
pdfPage.drawImage(pngImage, { x, y, width, height });
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to generate barcode for field "${field.name}":`,
|
||||
'Failed to generate barcode for field:',
|
||||
String(field.name).replace(/[\r\n]+/g, ' '),
|
||||
e
|
||||
);
|
||||
}
|
||||
@@ -2717,7 +2717,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||
const blob = new Blob([new Uint8Array(pdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, 'fillable-form.pdf');
|
||||
downloadFile(blob, uploadedFileName || 'document.pdf');
|
||||
showModal(
|
||||
'Success',
|
||||
'Your PDF has been downloaded successfully.',
|
||||
@@ -2861,181 +2861,53 @@ async function renderCanvas(): Promise<void> {
|
||||
|
||||
canvas.innerHTML = '';
|
||||
|
||||
if (uploadedPdfDoc) {
|
||||
if (uploadedPdfjsDoc) {
|
||||
try {
|
||||
const arrayBuffer = await uploadedPdfDoc.save();
|
||||
const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const pdfjsPage = await uploadedPdfjsDoc.getPage(currentPageIndex + 1);
|
||||
const viewport = pdfjsPage.getViewport({ scale: currentScale });
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `${import.meta.env.BASE_URL}pdfjs-viewer/viewer.html?file=${encodeURIComponent(blobUrl)}#page=${currentPageIndex + 1}&toolbar=0`;
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = `${canvasHeight}px`;
|
||||
iframe.style.border = 'none';
|
||||
iframe.style.position = 'absolute';
|
||||
iframe.style.top = '0';
|
||||
iframe.style.left = '0';
|
||||
iframe.style.pointerEvents = 'none';
|
||||
iframe.style.opacity = '0.8';
|
||||
const pageCanvas = document.createElement('canvas');
|
||||
pageCanvas.width = viewport.width;
|
||||
pageCanvas.height = viewport.height;
|
||||
pageCanvas.style.position = 'absolute';
|
||||
pageCanvas.style.top = '0';
|
||||
pageCanvas.style.left = '0';
|
||||
pageCanvas.style.pointerEvents = 'none';
|
||||
|
||||
iframe.onload = () => {
|
||||
try {
|
||||
const viewerWindow = iframe.contentWindow as PdfViewerWindow | null;
|
||||
if (viewerWindow && viewerWindow.PDFViewerApplication) {
|
||||
const app = viewerWindow.PDFViewerApplication;
|
||||
const ctx = pageCanvas.getContext('2d');
|
||||
if (ctx) {
|
||||
await pdfjsPage.render({
|
||||
canvasContext: ctx,
|
||||
viewport,
|
||||
canvas: pageCanvas,
|
||||
}).promise;
|
||||
}
|
||||
|
||||
const style = viewerWindow.document.createElement('style');
|
||||
style.textContent = `
|
||||
* {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
html, body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background-color: transparent !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
#toolbarContainer {
|
||||
display: none !important;
|
||||
}
|
||||
#mainContainer {
|
||||
top: 0 !important;
|
||||
position: absolute !important;
|
||||
left: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
#outerContainer {
|
||||
background-color: transparent !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
#viewerContainer {
|
||||
top: 0 !important;
|
||||
background-color: transparent !important;
|
||||
overflow: hidden !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.toolbar {
|
||||
display: none !important;
|
||||
}
|
||||
.pdfViewer {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.page {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`;
|
||||
viewerWindow.document.head.appendChild(style);
|
||||
canvas.appendChild(pageCanvas);
|
||||
|
||||
const checkRender = setInterval(() => {
|
||||
if (app.pdfViewer && app.pdfViewer.pagesCount > 0) {
|
||||
clearInterval(checkRender);
|
||||
if (pendingFieldExtraction && uploadedPdfDoc) {
|
||||
pendingFieldExtraction = false;
|
||||
extractExistingFields(uploadedPdfDoc);
|
||||
extractedFieldNames.forEach((name) => existingFieldNames.delete(name));
|
||||
|
||||
const pageContainer =
|
||||
viewerWindow.document.querySelector<HTMLElement>('.page');
|
||||
if (pageContainer) {
|
||||
const initialRect = pageContainer.getBoundingClientRect();
|
||||
|
||||
const offsetX = -initialRect.left;
|
||||
const offsetY = -initialRect.top;
|
||||
pageContainer.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
|
||||
|
||||
setTimeout(() => {
|
||||
const rect = pageContainer.getBoundingClientRect();
|
||||
const style = viewerWindow.getComputedStyle(pageContainer);
|
||||
|
||||
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
|
||||
const borderTop = parseFloat(style.borderTopWidth) || 0;
|
||||
const borderRight = parseFloat(style.borderRightWidth) || 0;
|
||||
|
||||
pdfViewerOffset = {
|
||||
x: rect.left + borderLeft,
|
||||
y: rect.top + borderTop,
|
||||
};
|
||||
|
||||
const contentWidth = rect.width - borderLeft - borderRight;
|
||||
pdfViewerScale = contentWidth / currentPage.width;
|
||||
|
||||
console.log('📏 Calibrated Metrics (force positioned):', {
|
||||
initialPosition: {
|
||||
left: initialRect.left,
|
||||
top: initialRect.top,
|
||||
},
|
||||
appliedTransform: { x: offsetX, y: offsetY },
|
||||
finalRect: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
computedBorders: {
|
||||
left: borderLeft,
|
||||
top: borderTop,
|
||||
right: borderRight,
|
||||
},
|
||||
finalOffset: pdfViewerOffset,
|
||||
finalScale: pdfViewerScale,
|
||||
pdfDimensions: {
|
||||
width: currentPage.width,
|
||||
height: currentPage.height,
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingFieldExtraction && uploadedPdfDoc) {
|
||||
pendingFieldExtraction = false;
|
||||
extractExistingFields(uploadedPdfDoc);
|
||||
extractedFieldNames.forEach((name) =>
|
||||
existingFieldNames.delete(name)
|
||||
);
|
||||
|
||||
const form = uploadedPdfDoc.getForm();
|
||||
for (const name of extractedFieldNames) {
|
||||
try {
|
||||
const existingField = form.getFieldMaybe(name);
|
||||
if (existingField) {
|
||||
form.removeField(existingField);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to remove extracted field "${name}" after import:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderCanvas();
|
||||
updateFieldCount();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
const form = uploadedPdfDoc.getForm();
|
||||
for (const name of extractedFieldNames) {
|
||||
try {
|
||||
const existingField = form.getFieldMaybe(name);
|
||||
if (existingField) {
|
||||
form.removeField(existingField);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to remove extracted field "${name}" after import:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error accessing iframe content:', e);
|
||||
}
|
||||
};
|
||||
|
||||
canvas.appendChild(iframe);
|
||||
|
||||
console.log('Canvas dimensions:', {
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
scale: currentScale,
|
||||
});
|
||||
console.log('PDF page dimensions:', {
|
||||
width: currentPage.width,
|
||||
height: currentPage.height,
|
||||
});
|
||||
renderCanvas();
|
||||
updateFieldCount();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rendering PDF:', error);
|
||||
}
|
||||
@@ -3114,8 +2986,8 @@ function extractExistingFields(pdfDoc: PDFDocument): void {
|
||||
pdfDoc,
|
||||
fieldCounterStart: fieldCounter,
|
||||
metrics: {
|
||||
pdfViewerOffset,
|
||||
pdfViewerScale,
|
||||
pdfViewerOffset: { x: 0, y: 0 },
|
||||
pdfViewerScale: currentScale,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3141,6 +3013,7 @@ async function handlePdfUpload(file: File) {
|
||||
const arrayBuffer = result.bytes;
|
||||
uploadedPdfjsDoc = result.pdf;
|
||||
uploadedPdfDoc = await loadPdfDocument(arrayBuffer);
|
||||
uploadedFileName = file.name;
|
||||
|
||||
// Check for existing fields and update counter
|
||||
existingFieldNames.clear();
|
||||
|
||||
@@ -54,19 +54,42 @@ function updateFileDisplay() {
|
||||
? `${(currentFile.size / 1024).toFixed(1)} KB`
|
||||
: `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
|
||||
displayArea.innerHTML = `
|
||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="truncate font-medium text-white">${currentFile.name}</p>
|
||||
<p class="text-gray-400 text-sm">${fileSize}</p>
|
||||
</div>
|
||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
displayArea.textContent = '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className =
|
||||
'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'flex-1 min-w-0';
|
||||
|
||||
const nameP = document.createElement('p');
|
||||
nameP.className = 'truncate font-medium text-white';
|
||||
nameP.textContent = currentFile.name;
|
||||
|
||||
const sizeP = document.createElement('p');
|
||||
sizeP.className = 'text-gray-400 text-sm';
|
||||
sizeP.textContent = fileSize;
|
||||
|
||||
info.append(nameP, sizeP);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.id = 'remove-file';
|
||||
removeBtn.className =
|
||||
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2';
|
||||
removeBtn.title = 'Remove file';
|
||||
|
||||
const removeIcon = document.createElement('i');
|
||||
removeIcon.setAttribute('data-lucide', 'trash-2');
|
||||
removeIcon.className = 'w-4 h-4';
|
||||
removeBtn.appendChild(removeIcon);
|
||||
|
||||
row.append(info, removeBtn);
|
||||
card.appendChild(row);
|
||||
displayArea.appendChild(card);
|
||||
|
||||
createIcons({ icons });
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ async function addHeaderFooter() {
|
||||
const newPdfBytes = await pageState.pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'header-footer-added.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { downloadFile, formatBytes } from '../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
|
||||
import { showAlert, showLoader, hideLoader } from '@/js/ui.js';
|
||||
import { downloadFile, formatBytes } from '@/js/utils/helpers.js';
|
||||
import { loadPyMuPDF } from '@/js/utils/pymupdf-loader.js';
|
||||
import type { PyMuPDFInstance } from '@/types';
|
||||
import heic2any from 'heic2any';
|
||||
import {
|
||||
getSelectedQuality,
|
||||
compressImageFile,
|
||||
} from '../utils/image-compress.js';
|
||||
|
||||
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';
|
||||
} from '@/js/utils/image-compress.js';
|
||||
import {
|
||||
IMAGE_ACCEPT,
|
||||
IMAGE_FORMATS_LABEL,
|
||||
isValidImageFile,
|
||||
preprocessImageFile,
|
||||
} from '@/js/utils/image-input-utils.js';
|
||||
|
||||
let files: File[] = [];
|
||||
let pymupdf: PyMuPDFInstance | null = null;
|
||||
@@ -34,11 +34,11 @@ function initializePage() {
|
||||
const formatDisplay = document.getElementById('supported-formats');
|
||||
|
||||
if (formatDisplay) {
|
||||
formatDisplay.textContent = SUPPORTED_FORMATS_DISPLAY;
|
||||
formatDisplay.textContent = IMAGE_FORMATS_LABEL;
|
||||
}
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.accept = SUPPORTED_FORMATS;
|
||||
fileInput.accept = IMAGE_ACCEPT;
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
}
|
||||
|
||||
@@ -95,16 +95,6 @@ function handleFileUpload(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
return '.' + filename.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
function isValidImageFile(file: File): boolean {
|
||||
const ext = getFileExtension(file.name);
|
||||
const validExtensions = SUPPORTED_FORMATS.split(',');
|
||||
return validExtensions.includes(ext) || file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
function handleFiles(newFiles: FileList) {
|
||||
const validFiles = Array.from(newFiles).filter(isValidImageFile);
|
||||
|
||||
@@ -183,80 +173,6 @@ async function ensurePyMuPDF(): Promise<PyMuPDFInstance> {
|
||||
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}`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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}`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async function convertToPdf() {
|
||||
if (files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one image file.');
|
||||
@@ -270,7 +186,7 @@ async function convertToPdf() {
|
||||
const processedFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const processed = await preprocessFile(file);
|
||||
const processed = await preprocessImageFile(file);
|
||||
const compressed = await compressImageFile(processed, quality);
|
||||
processedFiles.push(compressed);
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -164,7 +164,7 @@ async function invertColors() {
|
||||
const newPdfBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'inverted.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert('Success', 'Colors inverted successfully!', 'success', () => {
|
||||
resetState();
|
||||
|
||||
+52
-36
@@ -10,6 +10,7 @@ import {
|
||||
showWasmRequiredDialog,
|
||||
WasmProvider,
|
||||
} from '../utils/wasm-provider.js';
|
||||
import { initI18n, t } from '../i18n/i18n';
|
||||
|
||||
const worker = new Worker(
|
||||
import.meta.env.BASE_URL + 'workers/json-to-pdf.worker.js'
|
||||
@@ -17,8 +18,8 @@ const worker = new Worker(
|
||||
|
||||
let selectedFiles: File[] = [];
|
||||
|
||||
const jsonFilesInput = document.getElementById('jsonFiles') as HTMLInputElement;
|
||||
const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement;
|
||||
let jsonFilesInput!: HTMLInputElement;
|
||||
let convertBtn!: HTMLButtonElement;
|
||||
const statusMessage = document.getElementById(
|
||||
'status-message'
|
||||
) as HTMLDivElement;
|
||||
@@ -72,27 +73,9 @@ function updateFileList() {
|
||||
});
|
||||
}
|
||||
|
||||
jsonFilesInput.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
selectedFiles = Array.from(target.files);
|
||||
convertBtn.disabled = selectedFiles.length === 0;
|
||||
updateFileList();
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
showStatus('Please select at least 1 JSON file', 'info');
|
||||
} else {
|
||||
showStatus(
|
||||
`${selectedFiles.length} file(s) selected. Ready to convert!`,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function convertJSONsToPDF() {
|
||||
if (selectedFiles.length === 0) {
|
||||
showStatus('Please select at least 1 JSON file', 'error');
|
||||
showStatus(t('tools:jsonToPdf.status.selectAtLeastOne'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,13 +87,13 @@ async function convertJSONsToPDF() {
|
||||
|
||||
try {
|
||||
convertBtn.disabled = true;
|
||||
showStatus('Reading files (Main Thread)...', 'info');
|
||||
showStatus(t('tools:jsonToPdf.status.readingFiles'), 'info');
|
||||
|
||||
const fileBuffers = await Promise.all(
|
||||
selectedFiles.map((file) => readFileAsArrayBuffer(file))
|
||||
);
|
||||
|
||||
showStatus('Converting JSONs to PDFs...', 'info');
|
||||
showStatus(t('tools:jsonToPdf.status.converting'), 'info');
|
||||
|
||||
worker.postMessage(
|
||||
{
|
||||
@@ -124,7 +107,10 @@ async function convertJSONsToPDF() {
|
||||
} catch (error) {
|
||||
console.error('Error reading files:', error);
|
||||
showStatus(
|
||||
`❌ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
t('tools:jsonToPdf.status.readError', {
|
||||
message:
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
convertBtn.disabled = false;
|
||||
@@ -141,7 +127,7 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
}>;
|
||||
|
||||
try {
|
||||
showStatus('Creating ZIP file...', 'info');
|
||||
showStatus(t('tools:jsonToPdf.status.creatingZip'), 'info');
|
||||
|
||||
const zip = new JSZip();
|
||||
pdfFiles.forEach(({ name, data }) => {
|
||||
@@ -157,10 +143,7 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
a.download = 'jsons-to-pdf.zip';
|
||||
downloadFile(zipBlob, 'jsons-to-pdf.zip');
|
||||
|
||||
showStatus(
|
||||
'✅ JSONs converted to PDF successfully! ZIP download started.',
|
||||
'success'
|
||||
);
|
||||
showStatus(t('tools:jsonToPdf.status.success'), 'success');
|
||||
|
||||
selectedFiles = [];
|
||||
jsonFilesInput.value = '';
|
||||
@@ -174,14 +157,20 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
} catch (error) {
|
||||
console.error('Error creating ZIP:', error);
|
||||
showStatus(
|
||||
`❌ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
t('tools:jsonToPdf.status.zipError', {
|
||||
message:
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
} else if (e.data.status === 'error') {
|
||||
const errorMessage = e.data.message || 'Unknown error occurred in worker.';
|
||||
const errorMessage = e.data.message || t('common.unknownError');
|
||||
console.error('Worker Error:', errorMessage);
|
||||
showStatus(`❌ Worker Error: ${errorMessage}`, 'error');
|
||||
showStatus(
|
||||
t('tools:jsonToPdf.status.workerError', { message: errorMessage }),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,8 +180,35 @@ if (backToToolsBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
convertBtn.addEventListener('click', convertJSONsToPDF);
|
||||
// Initialize after i18n is ready so the default status is translated.
|
||||
void (async () => {
|
||||
await initI18n();
|
||||
|
||||
// Initialize
|
||||
showStatus('Select JSON files to get started', 'info');
|
||||
initializeGlobalShortcuts();
|
||||
jsonFilesInput = document.getElementById('jsonFiles') as HTMLInputElement;
|
||||
convertBtn = document.getElementById('convertBtn') as HTMLButtonElement;
|
||||
|
||||
jsonFilesInput.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
selectedFiles = Array.from(target.files);
|
||||
convertBtn.disabled = selectedFiles.length === 0;
|
||||
updateFileList();
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
showStatus(t('tools:jsonToPdf.status.selectAtLeastOne'), 'info');
|
||||
} else {
|
||||
showStatus(
|
||||
t('tools:jsonToPdf.status.selectedReady', {
|
||||
count: selectedFiles.length,
|
||||
}),
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
convertBtn.addEventListener('click', convertJSONsToPDF);
|
||||
|
||||
showStatus(t('tools:jsonToPdf.status.getStarted'), 'info');
|
||||
initializeGlobalShortcuts();
|
||||
})();
|
||||
@@ -385,11 +385,16 @@ export async function merge() {
|
||||
}
|
||||
}
|
||||
|
||||
const retainCheckbox = document.getElementById(
|
||||
'retain-page-labels'
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
const message: MergeMessage = {
|
||||
command: 'merge',
|
||||
files: filesToMerge,
|
||||
jobs: jobs,
|
||||
cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
|
||||
retainPageLabels: retainCheckbox?.checked ?? false,
|
||||
};
|
||||
|
||||
mergeWorker.postMessage(
|
||||
|
||||
@@ -200,11 +200,9 @@ async function nUpTool() {
|
||||
}
|
||||
|
||||
const newPdfBytes = await newDoc.save();
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_${n}-up.pdf`
|
||||
pageState.file.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveConfiguredTesseractAvailableLanguages,
|
||||
UnsupportedOcrLanguageError,
|
||||
} from '../utils/tesseract-language-availability.js';
|
||||
import { initI18n, t } from '../i18n/index.js';
|
||||
|
||||
const pageState: OcrState = {
|
||||
file: null,
|
||||
@@ -37,7 +38,7 @@ function updateProgress(status: string, progress: number) {
|
||||
progressStatus.textContent = status;
|
||||
progressBar.style.width = `${Math.min(100, progress * 100)}%`;
|
||||
|
||||
const logMessage = `Status: ${status}`;
|
||||
const logMessage = t('tools:ocrPdf.statusPrefix', { status });
|
||||
progressLog.textContent += logMessage + '\n';
|
||||
progressLog.scrollTop = progressLog.scrollHeight;
|
||||
}
|
||||
@@ -78,7 +79,9 @@ function resetState() {
|
||||
const selectedLangsDisplay = document.getElementById(
|
||||
'selected-langs-display'
|
||||
);
|
||||
if (selectedLangsDisplay) selectedLangsDisplay.textContent = 'None';
|
||||
if (selectedLangsDisplay) {
|
||||
selectedLangsDisplay.textContent = t('tools:ocrPdf.none');
|
||||
}
|
||||
|
||||
const processBtn = document.getElementById(
|
||||
'process-btn'
|
||||
@@ -100,14 +103,15 @@ function updateLanguageAvailabilityNotice() {
|
||||
const availableEntries = getAvailableTesseractLanguageEntries();
|
||||
if (availableEntries.length === 0) {
|
||||
notice.classList.remove('hidden');
|
||||
notice.textContent =
|
||||
'This deployment does not expose any valid OCR languages. Rebuild it with VITE_TESSERACT_AVAILABLE_LANGUAGES set to valid Tesseract codes.';
|
||||
notice.textContent = t('tools:ocrPdf.availabilityInvalidConfig');
|
||||
return;
|
||||
}
|
||||
|
||||
const availableNames = availableEntries.map(([, name]) => name).join(', ');
|
||||
notice.classList.remove('hidden');
|
||||
notice.textContent = `This deployment bundles OCR for: ${availableNames}.`;
|
||||
notice.textContent = t('tools:ocrPdf.availabilityBundles', {
|
||||
languages: availableNames,
|
||||
});
|
||||
}
|
||||
|
||||
async function runOCR() {
|
||||
@@ -131,14 +135,14 @@ async function runOCR() {
|
||||
|
||||
if (selectedLangs.length === 0) {
|
||||
showAlert(
|
||||
'No Languages Selected',
|
||||
'Please select at least one language for OCR.'
|
||||
t('tools:ocrPdf.noLanguagesTitle'),
|
||||
t('tools:ocrPdf.noLanguagesMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pageState.file) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
showAlert(t('tools:ocrPdf.noFileTitle'), t('tools:ocrPdf.noFileMessage'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,11 +181,11 @@ async function runOCR() {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof UnsupportedOcrLanguageError) {
|
||||
showAlert('OCR Language Not Available', e.message);
|
||||
showAlert(t('tools:ocrPdf.languageUnavailableTitle'), e.message);
|
||||
} else {
|
||||
showAlert(
|
||||
'OCR Error',
|
||||
'An error occurred during the OCR process. The worker may have failed to load. Please try again.'
|
||||
t('tools:ocrPdf.ocrErrorTitle'),
|
||||
t('tools:ocrPdf.ocrErrorMessage')
|
||||
);
|
||||
}
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
@@ -258,8 +262,7 @@ function populateLanguageList() {
|
||||
if (availableEntries.length === 0) {
|
||||
const emptyState = document.createElement('p');
|
||||
emptyState.className = 'text-sm text-yellow-300 p-2';
|
||||
emptyState.textContent =
|
||||
'No OCR languages are available in this deployment.';
|
||||
emptyState.textContent = t('tools:ocrPdf.noLanguagesAvailable');
|
||||
langList.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
@@ -282,7 +285,7 @@ function populateLanguageList() {
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById(
|
||||
@@ -304,6 +307,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const downloadTxtBtn = document.getElementById('download-txt-btn');
|
||||
const downloadPdfBtn = document.getElementById('download-searchable-pdf');
|
||||
|
||||
await initI18n();
|
||||
populateLanguageList();
|
||||
updateLanguageAvailabilityNotice();
|
||||
|
||||
@@ -376,7 +380,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
if (selectedLangsDisplay) {
|
||||
selectedLangsDisplay.textContent =
|
||||
selected.length > 0 ? selected.join(', ') : 'None';
|
||||
selected.length > 0 ? selected.join(', ') : t('tools:ocrPdf.none');
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
@@ -461,7 +465,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
new Blob([new Uint8Array(pageState.searchablePdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
'searchable.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -380,10 +380,9 @@ async function saveChanges() {
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const baseName = organizeState.file?.name.replace('.pdf', '') || 'document';
|
||||
downloadFile(
|
||||
new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
`${baseName}_organized.pdf`
|
||||
organizeState.file?.name || 'document.pdf'
|
||||
);
|
||||
|
||||
hideLoader();
|
||||
|
||||
@@ -177,12 +177,9 @@ async function processOverlay() {
|
||||
}
|
||||
|
||||
const modeLabel = mode.replace('--', '');
|
||||
const baseName = pageState.baseFile.name.replace(/\.pdf$/i, '');
|
||||
const fileName = `${baseName}_${modeLabel}.pdf`;
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(outputFile)], { type: 'application/pdf' }),
|
||||
fileName
|
||||
pageState.baseFile.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -181,7 +181,7 @@ async function addPageNumbers() {
|
||||
new Blob([resultBytes as unknown as BlobPart], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
'paginated.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert('Success', 'Page numbers added successfully!', 'success', () => {
|
||||
resetState();
|
||||
|
||||
@@ -524,12 +524,9 @@ async function createBooklet() {
|
||||
}
|
||||
|
||||
const pdfBytes = await outputDoc.save();
|
||||
const originalName =
|
||||
pageState.file?.name.replace(/\.pdf$/i, '') || 'document';
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_booklet.pdf`
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { t } from '../i18n/i18n';
|
||||
import {
|
||||
downloadFile,
|
||||
escapeHtml,
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
getPDFDocument,
|
||||
@@ -208,7 +209,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
|
||||
<label class="layer-toggle">
|
||||
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
|
||||
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${layer.text || `Layer ${layer.number}`}</span>
|
||||
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${escapeHtml(layer.text || `Layer ${layer.number}`)}</span>
|
||||
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
|
||||
</label>
|
||||
<div class="layer-actions">
|
||||
@@ -423,9 +424,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const blob = new Blob([new Uint8Array(pdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const outName =
|
||||
currentFile!.name.replace(/\.pdf$/i, '') + '_layers.pdf';
|
||||
downloadFile(blob, outName);
|
||||
downloadFile(blob, currentFile!.name);
|
||||
hideLoader();
|
||||
resetState();
|
||||
showAlert('Success', 'PDF with layer changes saved!', 'success');
|
||||
|
||||
@@ -193,6 +193,15 @@ function initializeTool() {
|
||||
document
|
||||
.getElementById('pdf-file-input')
|
||||
?.addEventListener('change', handlePdfUpload);
|
||||
document.getElementById('upload-area')?.addEventListener('click', () => {
|
||||
document.getElementById('pdf-file-input')?.click();
|
||||
});
|
||||
document
|
||||
.getElementById('pdf-file-input-select-btn')
|
||||
?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.getElementById('pdf-file-input')?.click();
|
||||
});
|
||||
document
|
||||
.getElementById('insert-pdf-input')
|
||||
?.addEventListener('change', handleInsertPdf);
|
||||
|
||||
@@ -142,7 +142,7 @@ async function convert() {
|
||||
const resultBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
|
||||
'greyscale.pdf'
|
||||
files[0]?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
+29
-17
@@ -12,6 +12,7 @@ import {
|
||||
WasmProvider,
|
||||
} from '../utils/wasm-provider.js';
|
||||
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
|
||||
import { initI18n, t } from '../i18n/i18n';
|
||||
|
||||
const worker = new Worker(
|
||||
import.meta.env.BASE_URL + 'workers/pdf-to-json.worker.js'
|
||||
@@ -82,10 +83,12 @@ pdfFilesInput.addEventListener('change', (e) => {
|
||||
updateFileList();
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
showStatus('Please select at least 1 PDF file', 'info');
|
||||
showStatus(t('tools:pdfToJson.status.selectAtLeastOne'), 'info');
|
||||
} else {
|
||||
showStatus(
|
||||
`${selectedFiles.length} file(s) selected. Ready to convert!`,
|
||||
t('tools:pdfToJson.status.selectedReady', {
|
||||
count: selectedFiles.length,
|
||||
}),
|
||||
'info'
|
||||
);
|
||||
}
|
||||
@@ -94,7 +97,7 @@ pdfFilesInput.addEventListener('change', (e) => {
|
||||
|
||||
async function convertPDFsToJSON() {
|
||||
if (selectedFiles.length === 0) {
|
||||
showStatus('Please select at least 1 PDF file', 'error');
|
||||
showStatus(t('tools:pdfToJson.status.selectAtLeastOne'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,17 +109,17 @@ async function convertPDFsToJSON() {
|
||||
|
||||
try {
|
||||
convertBtn.disabled = true;
|
||||
showStatus('Checking for encrypted PDFs...', 'info');
|
||||
showStatus(t('tools:pdfToJson.status.checkingEncrypted'), 'info');
|
||||
|
||||
selectedFiles = await batchDecryptIfNeeded(selectedFiles);
|
||||
|
||||
showStatus('Reading files (Main Thread)...', 'info');
|
||||
showStatus(t('tools:pdfToJson.status.readingFiles'), 'info');
|
||||
|
||||
const fileBuffers = await Promise.all(
|
||||
selectedFiles.map((file) => readFileAsArrayBuffer(file))
|
||||
);
|
||||
|
||||
showStatus('Converting PDFs to JSON..', 'info');
|
||||
showStatus(t('tools:pdfToJson.status.converting'), 'info');
|
||||
|
||||
worker.postMessage(
|
||||
{
|
||||
@@ -130,7 +133,10 @@ async function convertPDFsToJSON() {
|
||||
} catch (error) {
|
||||
console.error('Error reading files:', error);
|
||||
showStatus(
|
||||
`❌ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
t('tools:pdfToJson.status.readError', {
|
||||
message:
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
convertBtn.disabled = false;
|
||||
@@ -147,7 +153,7 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
}>;
|
||||
|
||||
try {
|
||||
showStatus('Creating ZIP file...', 'info');
|
||||
showStatus(t('tools:pdfToJson.status.creatingZip'), 'info');
|
||||
|
||||
const zip = new JSZip();
|
||||
const usedNames = new Set<string>();
|
||||
@@ -161,10 +167,7 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'pdfs-to-json.zip');
|
||||
|
||||
showStatus(
|
||||
'✅ PDFs converted to JSON successfully! ZIP download started.',
|
||||
'success'
|
||||
);
|
||||
showStatus(t('tools:pdfToJson.status.success'), 'success');
|
||||
|
||||
selectedFiles = [];
|
||||
pdfFilesInput.value = '';
|
||||
@@ -178,14 +181,20 @@ worker.onmessage = async (e: MessageEvent) => {
|
||||
} catch (error) {
|
||||
console.error('Error creating ZIP:', error);
|
||||
showStatus(
|
||||
`❌ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
t('tools:pdfToJson.status.zipError', {
|
||||
message:
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
} else if (e.data.status === 'error') {
|
||||
const errorMessage = e.data.message || 'Unknown error occurred in worker.';
|
||||
const errorMessage = e.data.message || t('common.unknownError');
|
||||
console.error('Worker Error:', errorMessage);
|
||||
showStatus(`❌ Worker Error: ${errorMessage}`, 'error');
|
||||
showStatus(
|
||||
t('tools:pdfToJson.status.workerError', { message: errorMessage }),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,5 +206,8 @@ if (backToToolsBtn) {
|
||||
|
||||
convertBtn.addEventListener('click', convertPDFsToJSON);
|
||||
|
||||
showStatus('Select PDF files to get started', 'info');
|
||||
initializeGlobalShortcuts();
|
||||
void (async () => {
|
||||
await initI18n();
|
||||
showStatus(t('tools:pdfToJson.status.getStarted'), 'info');
|
||||
initializeGlobalShortcuts();
|
||||
})();
|
||||
@@ -1,41 +1,39 @@
|
||||
import { showAlert } from '../ui.js';
|
||||
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
|
||||
import { executeWorkflow } from '../workflow/engine';
|
||||
import { getAvailableTesseractLanguageEntries } from '../utils/tesseract-language-availability.js';
|
||||
import { showAlert } from '@/js/ui.js';
|
||||
import { createWorkflowEditor, updateNodeDisplay } from '@/js/workflow/editor';
|
||||
import type { WorkflowEditor } from '@/js/workflow/editor';
|
||||
import { executeWorkflow } from '@/js/workflow/engine';
|
||||
import { translateCategory, translateNodeLabel } from '@/js/workflow/i18n';
|
||||
import type { NodeCategory } from '@/js/workflow/types';
|
||||
import {
|
||||
type BaseWorkflowNode,
|
||||
nodeRegistry,
|
||||
getNodesByCategory,
|
||||
createNodeByType,
|
||||
} from '../workflow/nodes/registry';
|
||||
import { isToolDisabled } from '../utils/disabled-tools.js';
|
||||
import type { BaseWorkflowNode } from '../workflow/nodes/base-node';
|
||||
import type { WorkflowEditor } from '../workflow/editor';
|
||||
import {
|
||||
PDFInputNode,
|
||||
EncryptedPDFError,
|
||||
} from '../workflow/nodes/pdf-input-node';
|
||||
import { ImageInputNode } from '../workflow/nodes/image-input-node';
|
||||
import { WordToPdfNode } from '../workflow/nodes/word-to-pdf-node';
|
||||
import { ExcelToPdfNode } from '../workflow/nodes/excel-to-pdf-node';
|
||||
import { PowerPointToPdfNode } from '../workflow/nodes/powerpoint-to-pdf-node';
|
||||
import { TextToPdfNode } from '../workflow/nodes/text-to-pdf-node';
|
||||
import { SvgToPdfNode } from '../workflow/nodes/svg-to-pdf-node';
|
||||
import { EpubToPdfNode } from '../workflow/nodes/epub-to-pdf-node';
|
||||
import { EmailToPdfNode } from '../workflow/nodes/email-to-pdf-node';
|
||||
import { DigitalSignNode } from '../workflow/nodes/digital-sign-node';
|
||||
import { XpsToPdfNode } from '../workflow/nodes/xps-to-pdf-node';
|
||||
import { MobiToPdfNode } from '../workflow/nodes/mobi-to-pdf-node';
|
||||
import { Fb2ToPdfNode } from '../workflow/nodes/fb2-to-pdf-node';
|
||||
import { CbzToPdfNode } from '../workflow/nodes/cbz-to-pdf-node';
|
||||
import { MarkdownToPdfNode } from '../workflow/nodes/markdown-to-pdf-node';
|
||||
import { JsonToPdfNode } from '../workflow/nodes/json-to-pdf-node';
|
||||
import { XmlToPdfNode } from '../workflow/nodes/xml-to-pdf-node';
|
||||
import { WpdToPdfNode } from '../workflow/nodes/wpd-to-pdf-node';
|
||||
import { WpsToPdfNode } from '../workflow/nodes/wps-to-pdf-node';
|
||||
import { PagesToPdfNode } from '../workflow/nodes/pages-to-pdf-node';
|
||||
import { OdgToPdfNode } from '../workflow/nodes/odg-to-pdf-node';
|
||||
import { PubToPdfNode } from '../workflow/nodes/pub-to-pdf-node';
|
||||
import { VsdToPdfNode } from '../workflow/nodes/vsd-to-pdf-node';
|
||||
ImageInputNode,
|
||||
WordToPdfNode,
|
||||
ExcelToPdfNode,
|
||||
PowerPointToPdfNode,
|
||||
TextToPdfNode,
|
||||
SvgToPdfNode,
|
||||
EpubToPdfNode,
|
||||
EmailToPdfNode,
|
||||
DigitalSignNode,
|
||||
XpsToPdfNode,
|
||||
MobiToPdfNode,
|
||||
Fb2ToPdfNode,
|
||||
CbzToPdfNode,
|
||||
MarkdownToPdfNode,
|
||||
JsonToPdfNode,
|
||||
XmlToPdfNode,
|
||||
WpdToPdfNode,
|
||||
WpsToPdfNode,
|
||||
PagesToPdfNode,
|
||||
OdgToPdfNode,
|
||||
PubToPdfNode,
|
||||
VsdToPdfNode,
|
||||
} from '@/js/workflow/nodes';
|
||||
import {
|
||||
saveWorkflow,
|
||||
loadWorkflow,
|
||||
@@ -44,7 +42,10 @@ import {
|
||||
getSavedTemplateNames,
|
||||
templateNameExists,
|
||||
deleteTemplate,
|
||||
} from '../workflow/serialization';
|
||||
} from '@/js/workflow/serialization';
|
||||
import { getAvailableTesseractLanguageEntries } from '@/js/utils/tesseract-language-availability.js';
|
||||
import { isToolDisabled } from '@/js/utils/disabled-tools.js';
|
||||
import { IMAGE_ACCEPT } from '@/js/utils/image-input-utils.js';
|
||||
|
||||
let workflowEditor: WorkflowEditor | null = null;
|
||||
let selectedNodeId: string | null = null;
|
||||
@@ -426,31 +427,22 @@ function buildToolbox() {
|
||||
if (!container) return;
|
||||
|
||||
const categorized = getNodesByCategory();
|
||||
const categoryOrder: Array<{ key: string; label: string; color: string }> = [
|
||||
{ key: 'Input', label: 'Input', color: 'text-blue-400' },
|
||||
{
|
||||
key: 'Edit & Annotate',
|
||||
label: 'Edit & Annotate',
|
||||
color: 'text-indigo-300',
|
||||
},
|
||||
{
|
||||
key: 'Organize & Manage',
|
||||
label: 'Organize & Manage',
|
||||
color: 'text-violet-300',
|
||||
},
|
||||
{
|
||||
key: 'Optimize & Repair',
|
||||
label: 'Optimize & Repair',
|
||||
color: 'text-amber-300',
|
||||
},
|
||||
{ key: 'Secure PDF', label: 'Secure PDF', color: 'text-rose-300' },
|
||||
{ key: 'Output', label: 'Output', color: 'text-teal-300' },
|
||||
const categoryOrder: Array<{
|
||||
key: NodeCategory;
|
||||
color: string;
|
||||
}> = [
|
||||
{ key: 'Input', color: 'text-blue-400' },
|
||||
{ key: 'Edit & Annotate', color: 'text-indigo-300' },
|
||||
{ key: 'Organize & Manage', color: 'text-violet-300' },
|
||||
{ key: 'Optimize & Repair', color: 'text-amber-300' },
|
||||
{ key: 'Secure PDF', color: 'text-rose-300' },
|
||||
{ key: 'Output', color: 'text-teal-300' },
|
||||
];
|
||||
|
||||
for (const cat of categoryOrder) {
|
||||
const entries = (
|
||||
categorized[cat.key as keyof typeof categorized] ?? []
|
||||
).filter((entry) => !entry.toolPageId || !isToolDisabled(entry.toolPageId));
|
||||
const entries = (categorized[cat.key] ?? []).filter(
|
||||
(entry) => !entry.toolPageId || !isToolDisabled(entry.toolPageId)
|
||||
);
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const section = document.createElement('div');
|
||||
@@ -461,7 +453,7 @@ function buildToolbox() {
|
||||
header.type = 'button';
|
||||
|
||||
const headerLabel = document.createElement('span');
|
||||
headerLabel.textContent = cat.label;
|
||||
headerLabel.textContent = translateCategory(cat.key);
|
||||
header.appendChild(headerLabel);
|
||||
|
||||
const chevronWrap = document.createElement('span');
|
||||
@@ -485,17 +477,19 @@ function buildToolbox() {
|
||||
const item = document.createElement('button');
|
||||
item.className =
|
||||
'toolbox-node-item w-full text-left px-2 py-1.5 rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors text-xs flex items-center gap-2';
|
||||
item.dataset.label = entry.label;
|
||||
item.dataset.type = Object.keys(nodeRegistry).find(
|
||||
const nodeType = Object.keys(nodeRegistry).find(
|
||||
(k) => nodeRegistry[k] === entry
|
||||
)!;
|
||||
const translatedLabel = translateNodeLabel(nodeType, entry);
|
||||
item.dataset.label = translatedLabel;
|
||||
item.dataset.type = nodeType;
|
||||
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = `ph ${entry.icon} text-sm flex-shrink-0`;
|
||||
item.appendChild(iconEl);
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.textContent = entry.label;
|
||||
labelEl.textContent = translatedLabel;
|
||||
item.appendChild(labelEl);
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
@@ -554,7 +548,10 @@ async function addNodeToCanvas(
|
||||
try {
|
||||
const node = createNodeByType(type);
|
||||
if (!node) {
|
||||
console.error('Node type not found in registry:', type);
|
||||
console.error(
|
||||
'Node type not found in registry:',
|
||||
String(type).replace(/[\r\n]+/g, ' ')
|
||||
);
|
||||
return;
|
||||
}
|
||||
await editor.addNode(node);
|
||||
@@ -762,7 +759,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.accept = IMAGE_ACCEPT;
|
||||
fileInput.multiple = true;
|
||||
fileInput.className = 'hidden';
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
@@ -1288,6 +1285,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
'subsetFonts',
|
||||
'convertToGrayscale',
|
||||
'removeThumbnails',
|
||||
'retainPageLabels',
|
||||
]);
|
||||
const multiSelectDropdowns = new Set(['language']);
|
||||
const advancedControls = new Set(['resolution', 'binarize', 'whitelist']);
|
||||
@@ -1309,6 +1307,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
y0: 'Top edge in points',
|
||||
x1: 'Right edge in points',
|
||||
y1: 'Bottom edge in points',
|
||||
retainPageLabels:
|
||||
"Off (default): natural 1–N numbering. On: each file's original page labels are preserved (may produce duplicate labels).",
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
|
||||
@@ -310,7 +310,7 @@ async function posterize() {
|
||||
const newPdfBytes = await newDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'posterized.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
|
||||
showAlert('Success', 'Your PDF has been posterized.');
|
||||
|
||||
@@ -146,8 +146,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
|
||||
downloadFile(rasterizedBlob, outName);
|
||||
downloadFile(rasterizedBlob, file.name);
|
||||
|
||||
hideLoader();
|
||||
showAlert(
|
||||
@@ -177,9 +176,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
quality: 95,
|
||||
});
|
||||
|
||||
const outName =
|
||||
file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
|
||||
const zipEntryName = deduplicateFileName(outName, usedNames);
|
||||
const zipEntryName = deduplicateFileName(file.name, usedNames);
|
||||
zip.file(zipEntryName, rasterizedBlob);
|
||||
|
||||
completed++;
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function redact(redactions: RedactionRect[], canvasScale: number) {
|
||||
const redactedBytes = await state.pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(redactedBytes)], { type: 'application/pdf' }),
|
||||
'redacted.pdf'
|
||||
state.files[0]?.name || 'document.pdf'
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PDFDocument, PDFName } from 'pdf-lib';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import { escapeHtml } from '../utils/helpers.js';
|
||||
|
||||
// State management
|
||||
const pageState: { pdfDoc: PDFDocument | null; file: File | null } = {
|
||||
@@ -70,7 +71,7 @@ function updateFileDisplay() {
|
||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="truncate font-medium text-white">${pageState.file.name}</p>
|
||||
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
|
||||
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||
@@ -144,7 +145,7 @@ async function processRemoveAnnotations() {
|
||||
const newPdfBytes = await pageState.pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'annotations-removed.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert('Success', 'Annotations removed successfully!', 'success', () => {
|
||||
resetState();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createIcons, icons } from 'lucide';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import { escapeHtml } from '../utils/helpers.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
@@ -79,7 +80,7 @@ function updateFileDisplay() {
|
||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="truncate font-medium text-white">${pageState.file.name}</p>
|
||||
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
|
||||
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
|
||||
@@ -313,7 +314,7 @@ async function processRemoveBlankPages() {
|
||||
const newPdfBytes = await newPdf.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'blank-pages-removed.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -158,7 +158,7 @@ async function removeMetadata() {
|
||||
const newPdfBytes = await pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
'metadata-removed.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert('Success', 'Metadata removed successfully!', 'success', () => {
|
||||
resetState();
|
||||
|
||||
@@ -153,7 +153,7 @@ async function removeRestrictions() {
|
||||
const blob = new Blob([new Uint8Array(outputFile)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `unrestricted-${pageState.file.name}`);
|
||||
downloadFile(blob, pageState.file.name);
|
||||
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
|
||||
|
||||
@@ -124,20 +124,16 @@ async function reversePages() {
|
||||
showLoader(`Reversing ${file.name} (${j + 1}/${validFiles.length})...`);
|
||||
|
||||
const newPdfBytes = await reverseSingleFile(file);
|
||||
const originalName = file.name.replace(/\.pdf$/i, '');
|
||||
const fileName = `${originalName}_reversed.pdf`;
|
||||
const zipEntryName = deduplicateFileName(fileName, usedNames);
|
||||
const zipEntryName = deduplicateFileName(file.name, usedNames);
|
||||
zip.file(zipEntryName, newPdfBytes);
|
||||
}
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
const file = validFiles[0];
|
||||
const newPdfBytes = await reverseSingleFile(file);
|
||||
const originalName = file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_reversed.pdf`
|
||||
file.name
|
||||
);
|
||||
} else {
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
|
||||
@@ -85,7 +85,7 @@ function createPageWrapper(
|
||||
|
||||
const canvasWrapper = document.createElement('div');
|
||||
canvasWrapper.className =
|
||||
'thumbnail-wrapper flex items-center justify-center p-2 h-64';
|
||||
'thumbnail-wrapper flex items-center justify-center p-2 h-64 pointer-events-none';
|
||||
canvasWrapper.style.transition = 'transform 0.3s ease';
|
||||
// Apply initial rotation if it exists (negated for canvas display)
|
||||
const initialRotation = pageState.rotations[pageIndex] || 0;
|
||||
@@ -322,11 +322,9 @@ async function applyRotations() {
|
||||
}
|
||||
|
||||
const rotatedPdfBytes = await newPdfDoc.save();
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(rotatedPdfBytes)], { type: 'application/pdf' }),
|
||||
`${originalName}_rotated.pdf`
|
||||
pageState.file.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -77,7 +77,7 @@ function createPageWrapper(
|
||||
|
||||
const canvasWrapper = document.createElement('div');
|
||||
canvasWrapper.className =
|
||||
'thumbnail-wrapper flex items-center justify-center p-2 h-36';
|
||||
'thumbnail-wrapper flex items-center justify-center p-2 h-36 pointer-events-none';
|
||||
canvasWrapper.style.transition = 'transform 0.3s ease';
|
||||
// Apply initial rotation if it exists
|
||||
const initialRotation = pageState.rotations[pageIndex] || 0;
|
||||
@@ -246,13 +246,11 @@ async function applyRotations() {
|
||||
new Uint8Array(pdfBytes),
|
||||
pageState.rotations
|
||||
);
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
downloadFile(
|
||||
new Blob([rotatedPdfBytes as unknown as BlobPart], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
`${originalName}_rotated.pdf`
|
||||
pageState.file.name
|
||||
);
|
||||
|
||||
showAlert(
|
||||
|
||||
@@ -147,7 +147,7 @@ async function runSanitize() {
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }),
|
||||
'sanitized.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -257,7 +257,7 @@ async function processAllPages(): Promise<void> {
|
||||
const resultBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
|
||||
'scanned.pdf'
|
||||
files[0]?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
|
||||
@@ -303,10 +303,7 @@ async function applyAndSaveSignatures() {
|
||||
const blob = new Blob([new Uint8Array(flattenedPdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(
|
||||
blob,
|
||||
`signed_flattened_${signState.file?.name || 'document.pdf'}`
|
||||
);
|
||||
downloadFile(blob, signState.file?.name || 'document.pdf');
|
||||
|
||||
hideLoader();
|
||||
showAlert('Success', 'Signed PDF saved successfully!', 'success', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
WasmProvider,
|
||||
} from '../utils/wasm-provider.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
const worker = new Worker(
|
||||
import.meta.env.BASE_URL + 'workers/table-of-contents.worker.js'
|
||||
@@ -128,7 +129,7 @@ fileInput.addEventListener('change', (e) => {
|
||||
|
||||
async function generateTableOfContents() {
|
||||
if (!pdfFile) {
|
||||
showStatus('Please select a PDF file first.', 'error');
|
||||
showStatus(t('tools:tableOfContents.selectPdfFirst'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,13 +141,13 @@ async function generateTableOfContents() {
|
||||
|
||||
try {
|
||||
generateBtn.disabled = true;
|
||||
showStatus('Reading file (Main Thread)...', 'info');
|
||||
showStatus(t('tools:tableOfContents.statusReadingFileMainThread'), 'info');
|
||||
|
||||
const arrayBuffer = await pdfFile.arrayBuffer();
|
||||
|
||||
showStatus('Generating table of contents...', 'info');
|
||||
showStatus(t('tools:tableOfContents.statusGenerating'), 'info');
|
||||
|
||||
const title = tocTitleInput.value || 'Table of Contents';
|
||||
const title = tocTitleInput.value || t('tools:tableOfContents.name');
|
||||
const fontSize = parseInt(fontSizeSelect.value, 10);
|
||||
const fontFamily = parseInt(fontFamilySelect.value, 10);
|
||||
const addBookmark = addBookmarkCheckbox.checked;
|
||||
@@ -165,7 +166,10 @@ async function generateTableOfContents() {
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
showStatus(
|
||||
`Error reading file: ${error instanceof Error ? error.message : 'Unknown error occurred'}`,
|
||||
t('tools:tableOfContents.errorReadingFileWithMessage', {
|
||||
message:
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
generateBtn.disabled = false;
|
||||
@@ -180,13 +184,10 @@ worker.onmessage = (e: MessageEvent<TOCWorkerResponse>) => {
|
||||
const pdfBytes = new Uint8Array(pdfBytesBuffer);
|
||||
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
downloadFile(
|
||||
blob,
|
||||
pdfFile?.name.replace('.pdf', '_with_toc.pdf') || 'output_with_toc.pdf'
|
||||
);
|
||||
downloadFile(blob, pdfFile?.name || 'document.pdf');
|
||||
|
||||
showStatus(
|
||||
'Table of contents generated successfully! Download started.',
|
||||
t('tools:tableOfContents.successGeneratedDownloadStarted'),
|
||||
'success'
|
||||
);
|
||||
|
||||
@@ -197,15 +198,20 @@ worker.onmessage = (e: MessageEvent<TOCWorkerResponse>) => {
|
||||
fileDisplayArea.classList.add('hidden');
|
||||
generateBtn.disabled = true;
|
||||
} else if (e.data.status === 'error') {
|
||||
const errorMessage = e.data.message || 'Unknown error occurred in worker.';
|
||||
const errorMessage = e.data.message || t('common.unknownError');
|
||||
console.error('Worker Error:', errorMessage);
|
||||
showStatus(`Error: ${errorMessage}`, 'error');
|
||||
showStatus(
|
||||
t('tools:tableOfContents.workerErrorWithMessage', {
|
||||
message: errorMessage,
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
console.error('Worker error:', error);
|
||||
showStatus('Worker error occurred. Check console for details.', 'error');
|
||||
showStatus(t('tools:tableOfContents.workerErrorOccurred'), 'error');
|
||||
generateBtn.disabled = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ async function changeTextColor() {
|
||||
const newPdfBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'text-color-changed.pdf'
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert('Success', 'Text color changed successfully!', 'success', () => {
|
||||
resetState();
|
||||
|
||||
+197
-158
@@ -1,200 +1,239 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import {
|
||||
downloadFile,
|
||||
formatBytes,
|
||||
readFileAsArrayBuffer,
|
||||
} from '../utils/helpers.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import { decode } from 'tiff';
|
||||
import { tiffIfdToRgba } from '../utils/tiff-utils.js';
|
||||
|
||||
let files: File[] = [];
|
||||
|
||||
const updateUI = () => {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
if (!fileDisplayArea || !fileControls || !processBtn) return;
|
||||
const optionsPanel = document.getElementById('tiff-options');
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
if (!fileDisplayArea || !fileControls || !processBtn) return;
|
||||
|
||||
if (files.length > 0) {
|
||||
fileControls.classList.remove('hidden');
|
||||
processBtn.classList.remove('hidden');
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
if (files.length > 0) {
|
||||
fileControls.classList.remove('hidden');
|
||||
processBtn.classList.remove('hidden');
|
||||
optionsPanel?.classList.remove('hidden');
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
|
||||
files.forEach((file, index) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||
nameSpan.textContent = file.name;
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex items-center gap-2 overflow-hidden';
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
|
||||
sizeSpan.textContent = `(${formatBytes(file.size)})`;
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
infoContainer.append(nameSpan, sizeSpan);
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
|
||||
sizeSpan.textContent = `(${formatBytes(file.size)})`;
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
files = files.filter((_, i) => i !== index);
|
||||
updateUI();
|
||||
};
|
||||
infoContainer.append(nameSpan, sizeSpan);
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
});
|
||||
createIcons({ icons });
|
||||
} else {
|
||||
fileControls.classList.add('hidden');
|
||||
processBtn.classList.add('hidden');
|
||||
}
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className =
|
||||
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
files = files.filter((_, i) => i !== index);
|
||||
updateUI();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
});
|
||||
createIcons({ icons });
|
||||
} else {
|
||||
fileControls.classList.add('hidden');
|
||||
processBtn.classList.add('hidden');
|
||||
optionsPanel?.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
files = [];
|
||||
updateUI();
|
||||
files = [];
|
||||
updateUI();
|
||||
};
|
||||
|
||||
async function convert() {
|
||||
if (files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one TIFF file.');
|
||||
return;
|
||||
}
|
||||
showLoader('Converting TIFF to PDF...');
|
||||
try {
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
for (const file of files) {
|
||||
const tiffBytes = await readFileAsArrayBuffer(file);
|
||||
const ifds = decode(tiffBytes as ArrayBuffer);
|
||||
if (files.length === 0) {
|
||||
showAlert('No Files', 'Please select at least one TIFF file.');
|
||||
return;
|
||||
}
|
||||
const qualitySelect = document.getElementById(
|
||||
'tiff-pdf-quality'
|
||||
) as HTMLSelectElement;
|
||||
const quality = qualitySelect?.value || 'medium';
|
||||
const jpegQualityMap: Record<string, number> = {
|
||||
high: 0.92,
|
||||
medium: 0.75,
|
||||
low: 0.5,
|
||||
};
|
||||
const useJpeg = quality !== 'high';
|
||||
const jpegQuality = jpegQualityMap[quality] || 0.75;
|
||||
|
||||
for (const ifd of ifds) {
|
||||
const width = ifd.width;
|
||||
const height = ifd.height;
|
||||
const rgba = ifd.data;
|
||||
showLoader('Converting TIFF to PDF...');
|
||||
try {
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
for (const file of files) {
|
||||
const tiffBytes = await readFileAsArrayBuffer(file);
|
||||
const ifds = decode(tiffBytes as ArrayBuffer);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
for (const ifd of ifds) {
|
||||
const width = ifd.width;
|
||||
const height = ifd.height;
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
for (let i = 0; i < rgba.length; i++) {
|
||||
imageData.data[i] = rgba[i];
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
|
||||
const pngBlob = await new Promise<Blob | null>((res) =>
|
||||
canvas.toBlob(res, 'image/png')
|
||||
);
|
||||
if (!pngBlob) continue;
|
||||
|
||||
const pngBytes = await pngBlob.arrayBuffer();
|
||||
const pngImage = await pdfDoc.embedPng(pngBytes);
|
||||
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
|
||||
page.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pngImage.width,
|
||||
height: pngImage.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'from_tiff.pdf'
|
||||
const rgba = tiffIfdToRgba(
|
||||
ifd.data,
|
||||
width,
|
||||
height,
|
||||
ifd.samplesPerPixel || 1,
|
||||
ifd.type
|
||||
);
|
||||
showAlert('Success', 'PDF created successfully!', 'success', () => {
|
||||
resetState();
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
imageData.data.set(rgba);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const blob = await new Promise<Blob | null>((res) =>
|
||||
canvas.toBlob(
|
||||
res,
|
||||
useJpeg ? 'image/jpeg' : 'image/png',
|
||||
useJpeg ? jpegQuality : undefined
|
||||
)
|
||||
);
|
||||
if (!blob) continue;
|
||||
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
const imgBytes = await blob.arrayBuffer();
|
||||
const image = useJpeg
|
||||
? await pdfDoc.embedJpg(imgBytes)
|
||||
: await pdfDoc.embedPng(imgBytes);
|
||||
const page = pdfDoc.addPage([image.width, image.height]);
|
||||
page.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to convert TIFF to PDF. One of the files may be invalid.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'from_tiff.pdf'
|
||||
);
|
||||
showAlert('Success', 'PDF created successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to convert TIFF to PDF. One of the files may be invalid.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const addMoreBtn = document.getElementById('add-more-btn');
|
||||
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const addMoreBtn = document.getElementById('add-more-btn');
|
||||
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
const handleFileSelect = (newFiles: FileList | null) => {
|
||||
if (!newFiles || newFiles.length === 0) return;
|
||||
const validFiles = Array.from(newFiles).filter(
|
||||
(file) =>
|
||||
file.type === 'image/tiff' ||
|
||||
file.name.toLowerCase().endsWith('.tiff') ||
|
||||
file.name.toLowerCase().endsWith('.tif')
|
||||
);
|
||||
|
||||
if (validFiles.length < newFiles.length) {
|
||||
showAlert(
|
||||
'Invalid Files',
|
||||
'Some files were skipped. Only TIFF files are allowed.'
|
||||
);
|
||||
}
|
||||
|
||||
const handleFileSelect = (newFiles: FileList | null) => {
|
||||
if (!newFiles || newFiles.length === 0) return;
|
||||
const validFiles = Array.from(newFiles).filter(
|
||||
(file) =>
|
||||
file.type === 'image/tiff' ||
|
||||
file.name.toLowerCase().endsWith('.tiff') ||
|
||||
file.name.toLowerCase().endsWith('.tif')
|
||||
);
|
||||
|
||||
if (validFiles.length < newFiles.length) {
|
||||
showAlert('Invalid Files', 'Some files were skipped. Only TIFF files are allowed.');
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
files = [...files, ...validFiles];
|
||||
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');
|
||||
handleFileSelect(e.dataTransfer?.files ?? null);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
if (validFiles.length > 0) {
|
||||
files = [...files, ...validFiles];
|
||||
updateUI();
|
||||
}
|
||||
};
|
||||
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', () => {
|
||||
fileInput?.click();
|
||||
});
|
||||
}
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
if (clearFilesBtn) {
|
||||
clearFilesBtn.addEventListener('click', () => {
|
||||
resetState();
|
||||
});
|
||||
}
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', convert);
|
||||
}
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
handleFileSelect(e.dataTransfer?.files ?? null);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', () => {
|
||||
fileInput?.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearFilesBtn) {
|
||||
clearFilesBtn.addEventListener('click', () => {
|
||||
resetState();
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', convert);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { t } from '../i18n/i18n';
|
||||
import {
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
@@ -197,13 +198,7 @@ function updateProcessButton(): void {
|
||||
const processBtn = getElement<HTMLButtonElement>('process-btn');
|
||||
if (!processBtn) return;
|
||||
|
||||
if (state.pdfBytes) {
|
||||
processBtn.classList.remove('hidden');
|
||||
processBtn.disabled = false;
|
||||
} else {
|
||||
processBtn.classList.add('hidden');
|
||||
processBtn.disabled = true;
|
||||
}
|
||||
processBtn.disabled = !state.pdfBytes;
|
||||
}
|
||||
|
||||
function getTsaUrl(): string | null {
|
||||
@@ -227,18 +222,14 @@ async function processTimestamp(): Promise<void> {
|
||||
try {
|
||||
const timestampedBytes = await timestampPdf(state.pdfBytes, tsaUrl);
|
||||
|
||||
const outputFilename = state.pdfFile.name.replace(
|
||||
/\.pdf$/i,
|
||||
'_timestamped.pdf'
|
||||
);
|
||||
const blob = new Blob([new Uint8Array(timestampedBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, outputFilename);
|
||||
downloadFile(blob, state.pdfFile.name);
|
||||
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF timestamped successfully! The timestamp can be verified in Adobe Acrobat and other PDF readers.',
|
||||
t('common.success'),
|
||||
t('tools:timestampPdf.successMessage'),
|
||||
'success'
|
||||
);
|
||||
|
||||
|
||||
@@ -6,303 +6,329 @@ import forge from 'node-forge';
|
||||
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
|
||||
|
||||
const state: ValidateSignatureState = {
|
||||
pdfFile: null,
|
||||
pdfBytes: null,
|
||||
results: [],
|
||||
trustedCertFile: null,
|
||||
trustedCert: null,
|
||||
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;
|
||||
return document.getElementById(id) as T | null;
|
||||
}
|
||||
|
||||
function resetState(): void {
|
||||
state.pdfFile = null;
|
||||
state.pdfBytes = null;
|
||||
state.results = [];
|
||||
state.pdfFile = null;
|
||||
state.pdfBytes = null;
|
||||
state.results = [];
|
||||
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||
if (resultsSection) resultsSection.classList.add('hidden');
|
||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||
if (resultsSection) resultsSection.classList.add('hidden');
|
||||
|
||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||
if (resultsContainer) resultsContainer.innerHTML = '';
|
||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||
if (resultsContainer) resultsContainer.innerHTML = '';
|
||||
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.add('hidden');
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
function resetCertState(): void {
|
||||
state.trustedCertFile = null;
|
||||
state.trustedCert = null;
|
||||
state.trustedCertFile = null;
|
||||
state.trustedCert = null;
|
||||
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (certDisplayArea) certDisplayArea.innerHTML = '';
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (certDisplayArea) certDisplayArea.innerHTML = '';
|
||||
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
if (certInput) certInput.value = '';
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
if (certInput) certInput.value = '';
|
||||
}
|
||||
|
||||
function initializePage(): void {
|
||||
createIcons({ icons });
|
||||
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');
|
||||
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 (fileInput) {
|
||||
fileInput.addEventListener('change', handlePdfUpload);
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
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('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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
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 (certInput) {
|
||||
certInput.addEventListener('change', handleCertUpload);
|
||||
certInput.addEventListener('click', () => {
|
||||
certInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (certDropZone) {
|
||||
certDropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
certDropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
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('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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
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]);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
resetState();
|
||||
state.pdfFile = file;
|
||||
state.pdfBytes = new Uint8Array(
|
||||
(await readFileAsArrayBuffer(file)) as ArrayBuffer
|
||||
);
|
||||
|
||||
updatePdfDisplay();
|
||||
updatePdfDisplay();
|
||||
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.remove('hidden');
|
||||
createIcons({ icons });
|
||||
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
|
||||
if (customCertSection) customCertSection.classList.remove('hidden');
|
||||
createIcons({ icons });
|
||||
|
||||
await validateSignatures();
|
||||
await validateSignatures();
|
||||
}
|
||||
|
||||
function updatePdfDisplay(): void {
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (!fileDisplayArea || !state.pdfFile) return;
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (!fileDisplayArea || !state.pdfFile) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
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 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 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);
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(state.pdfFile.size);
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
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();
|
||||
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 });
|
||||
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]);
|
||||
}
|
||||
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));
|
||||
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;
|
||||
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();
|
||||
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;
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
|
||||
|
||||
certDisplayArea.innerHTML = '';
|
||||
certDisplayArea.innerHTML = '';
|
||||
|
||||
const certDiv = document.createElement('div');
|
||||
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
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 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 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 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';
|
||||
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);
|
||||
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();
|
||||
}
|
||||
};
|
||||
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 });
|
||||
certDiv.append(infoContainer, removeBtn);
|
||||
certDisplayArea.appendChild(certDiv);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
async function validateSignatures(): Promise<void> {
|
||||
if (!state.pdfBytes) return;
|
||||
if (!state.pdfBytes) return;
|
||||
|
||||
showLoader('Analyzing signatures...');
|
||||
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();
|
||||
}
|
||||
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');
|
||||
const resultsSection = getElement<HTMLDivElement>('results-section');
|
||||
const resultsContainer = getElement<HTMLDivElement>('results-container');
|
||||
|
||||
if (!resultsSection || !resultsContainer) return;
|
||||
if (!resultsSection || !resultsContainer) return;
|
||||
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsSection.classList.remove('hidden');
|
||||
resultsContainer.innerHTML = '';
|
||||
resultsSection.classList.remove('hidden');
|
||||
|
||||
if (state.results.length === 0) {
|
||||
resultsContainer.innerHTML = `
|
||||
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;
|
||||
}
|
||||
createIcons({ icons });
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryDiv = document.createElement('div');
|
||||
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
|
||||
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;
|
||||
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 = `
|
||||
let summaryHtml = `
|
||||
<p class="text-gray-300">
|
||||
<span class="font-semibold text-white">${state.results.length}</span>
|
||||
signature${state.results.length > 1 ? 's' : ''} found
|
||||
@@ -311,69 +337,87 @@ function displayResults(): void {
|
||||
</p>
|
||||
`;
|
||||
|
||||
if (state.trustedCert) {
|
||||
summaryHtml += `
|
||||
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);
|
||||
summaryDiv.innerHTML = summaryHtml;
|
||||
resultsContainer.appendChild(summaryDiv);
|
||||
|
||||
state.results.forEach((result, index) => {
|
||||
const card = createSignatureCard(result, index);
|
||||
resultsContainer.appendChild(card);
|
||||
});
|
||||
state.results.forEach((result, index) => {
|
||||
const card = createSignatureCard(result, index);
|
||||
resultsContainer.appendChild(card);
|
||||
});
|
||||
|
||||
createIcons({ icons });
|
||||
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';
|
||||
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';
|
||||
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';
|
||||
if (!result.isValid) {
|
||||
if (result.cryptoVerificationStatus === 'unsupported') {
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Unverified — Unsupported Signature Algorithm';
|
||||
} else {
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
statusText =
|
||||
result.cryptoVerified === false
|
||||
? 'Invalid — Cryptographic Verification Failed'
|
||||
: 'Invalid Signature';
|
||||
}
|
||||
} else if (result.usesInsecureDigest) {
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
statusText = 'Insecure Digest (MD5 / SHA-1)';
|
||||
} 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'
|
||||
});
|
||||
};
|
||||
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>';
|
||||
}
|
||||
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 = `
|
||||
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>
|
||||
@@ -383,12 +427,13 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
||||
</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}
|
||||
${
|
||||
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>
|
||||
|
||||
@@ -407,12 +452,16 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${result.signatureDate ? `
|
||||
${
|
||||
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>
|
||||
@@ -425,19 +474,27 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${result.reason ? `
|
||||
${
|
||||
result.reason
|
||||
? `
|
||||
<div>
|
||||
<p class="text-gray-400">Reason</p>
|
||||
<p class="text-white">${escapeHtml(result.reason)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
${result.location ? `
|
||||
${
|
||||
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">
|
||||
@@ -448,22 +505,23 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
|
||||
<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>` : ''}
|
||||
${result.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
return card;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
initializePage();
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import forge from 'node-forge';
|
||||
import { ExtractedSignature, SignatureValidationResult } from '@/types';
|
||||
|
||||
const INSECURE_DIGEST_OIDS = new Set<string>([
|
||||
'1.2.840.113549.2.5',
|
||||
'1.3.14.3.2.26',
|
||||
]);
|
||||
|
||||
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
||||
const signatures: ExtractedSignature[] = [];
|
||||
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
|
||||
@@ -70,11 +75,11 @@ export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
||||
return signatures;
|
||||
}
|
||||
|
||||
export function validateSignature(
|
||||
export async function validateSignature(
|
||||
signature: ExtractedSignature,
|
||||
pdfBytes: Uint8Array,
|
||||
trustedCert?: forge.pki.Certificate
|
||||
): SignatureValidationResult {
|
||||
): Promise<SignatureValidationResult> {
|
||||
const result: SignatureValidationResult = {
|
||||
signatureIndex: signature.index,
|
||||
isValid: false,
|
||||
@@ -104,7 +109,10 @@ export function validateSignature(
|
||||
asn1
|
||||
) as forge.pkcs7.PkcsSignedData & {
|
||||
rawCapture?: {
|
||||
authenticatedAttributes?: Array<{ type: string; value: Date }>;
|
||||
digestAlgorithm?: string;
|
||||
authenticatedAttributes?: forge.asn1.Asn1[];
|
||||
signature?: string;
|
||||
signatureAlgorithm?: forge.asn1.Asn1[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -161,21 +169,47 @@ export function validateSignature(
|
||||
}
|
||||
}
|
||||
|
||||
const signerInfoFields = extractSignerInfoFields(p7);
|
||||
const digestOid = signerInfoFields?.digestOid;
|
||||
|
||||
result.algorithms = {
|
||||
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
|
||||
digest:
|
||||
(digestOid && getDigestAlgorithmName(digestOid)) ||
|
||||
getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
|
||||
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
|
||||
};
|
||||
|
||||
if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) {
|
||||
result.usesInsecureDigest = true;
|
||||
}
|
||||
|
||||
// Parse signing time if available in signature
|
||||
if (signature.signingTime) {
|
||||
result.signatureDate = new Date(signature.signingTime);
|
||||
} else {
|
||||
// Try to extract from authenticated attributes
|
||||
try {
|
||||
if (p7.rawCapture?.authenticatedAttributes) {
|
||||
for (const attr of p7.rawCapture.authenticatedAttributes) {
|
||||
if (attr.type === forge.pki.oids.signingTime) {
|
||||
result.signatureDate = attr.value;
|
||||
const attrs = p7.rawCapture?.authenticatedAttributes;
|
||||
if (attrs) {
|
||||
for (const attrNode of attrs) {
|
||||
const attrChildren = attrNode.value;
|
||||
if (!Array.isArray(attrChildren) || attrChildren.length < 2)
|
||||
continue;
|
||||
const oidNode = attrChildren[0];
|
||||
const setNode = attrChildren[1];
|
||||
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
|
||||
const oid = forge.asn1.derToOid(oidNode.value as string);
|
||||
if (oid === forge.pki.oids.signingTime) {
|
||||
const setValue = setNode?.value;
|
||||
if (Array.isArray(setValue) && setValue[0]) {
|
||||
const timeNode = setValue[0];
|
||||
const timeStr = timeNode.value as string;
|
||||
if (typeof timeStr === 'string' && timeStr.length > 0) {
|
||||
result.signatureDate =
|
||||
timeNode.type === forge.asn1.Type.UTCTIME
|
||||
? forge.asn1.utcTimeToDate(timeStr)
|
||||
: forge.asn1.generalizedTimeToDate(timeStr);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -190,7 +224,6 @@ export function validateSignature(
|
||||
|
||||
if (signature.byteRange && signature.byteRange.length === 4) {
|
||||
const [, len1, start2, len2] = signature.byteRange;
|
||||
const totalCovered = len1 + len2;
|
||||
const expectedEnd = start2 + len2;
|
||||
|
||||
if (expectedEnd === pdfBytes.length) {
|
||||
@@ -200,7 +233,27 @@ export function validateSignature(
|
||||
}
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
const verification = await performCryptoVerification(
|
||||
p7,
|
||||
pdfBytes,
|
||||
signature.byteRange,
|
||||
signerCert,
|
||||
signerInfoFields
|
||||
);
|
||||
|
||||
result.cryptoVerified = verification.status === 'verified';
|
||||
result.cryptoVerificationStatus = verification.status;
|
||||
if (verification.status === 'unsupported') {
|
||||
result.unsupportedAlgorithmReason = verification.reason;
|
||||
} else if (verification.status === 'failed') {
|
||||
result.errorMessage =
|
||||
verification.reason || 'Cryptographic verification failed';
|
||||
}
|
||||
|
||||
result.isValid =
|
||||
verification.status === 'verified' &&
|
||||
result.coverageStatus !== 'unknown' &&
|
||||
!result.usesInsecureDigest;
|
||||
} catch (e) {
|
||||
result.errorMessage =
|
||||
e instanceof Error ? e.message : 'Failed to parse signature';
|
||||
@@ -214,7 +267,9 @@ export async function validatePdfSignatures(
|
||||
trustedCert?: forge.pki.Certificate
|
||||
): Promise<SignatureValidationResult[]> {
|
||||
const signatures = extractSignatures(pdfBytes);
|
||||
return signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert));
|
||||
return Promise.all(
|
||||
signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert))
|
||||
);
|
||||
}
|
||||
|
||||
export function countSignatures(pdfBytes: Uint8Array): number {
|
||||
@@ -262,3 +317,490 @@ function getSignatureAlgorithmName(oid: string): string {
|
||||
};
|
||||
return signatureAlgorithms[oid] || oid || 'Unknown';
|
||||
}
|
||||
|
||||
interface SignerInfoFields {
|
||||
digestOid: string;
|
||||
authAttrs: forge.asn1.Asn1[] | null;
|
||||
signatureBytes: string;
|
||||
}
|
||||
|
||||
function extractSignerInfoFields(
|
||||
p7: forge.pkcs7.PkcsSignedData & {
|
||||
rawCapture?: {
|
||||
digestAlgorithm?: string;
|
||||
authenticatedAttributes?: forge.asn1.Asn1[];
|
||||
signature?: string;
|
||||
};
|
||||
}
|
||||
): SignerInfoFields | null {
|
||||
const rc = p7.rawCapture;
|
||||
if (!rc) return null;
|
||||
const digestAlgorithmBytes = rc.digestAlgorithm;
|
||||
const signatureBytes = rc.signature;
|
||||
if (typeof digestAlgorithmBytes !== 'string' || !signatureBytes) return null;
|
||||
|
||||
return {
|
||||
digestOid: forge.asn1.derToOid(digestAlgorithmBytes),
|
||||
authAttrs: Array.isArray(rc.authenticatedAttributes)
|
||||
? rc.authenticatedAttributes
|
||||
: null,
|
||||
signatureBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function createMd(digestOid: string): forge.md.MessageDigest | null {
|
||||
switch (digestOid) {
|
||||
case forge.pki.oids.sha256:
|
||||
return forge.md.sha256.create();
|
||||
case forge.pki.oids.sha384:
|
||||
return forge.md.sha384.create();
|
||||
case forge.pki.oids.sha512:
|
||||
return forge.md.sha512.create();
|
||||
case forge.pki.oids.sha1:
|
||||
return forge.md.sha1.create();
|
||||
case forge.pki.oids.md5:
|
||||
return forge.md.md5.create();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function uint8ToLatin1(bytes: Uint8Array): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
out += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type CryptoVerificationResult =
|
||||
| { status: 'verified' }
|
||||
| { status: 'failed'; reason: string }
|
||||
| { status: 'unsupported'; reason: string };
|
||||
|
||||
interface SigScheme {
|
||||
kind: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa' | 'rsa-raw';
|
||||
hashName: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||
pssSaltLength?: number;
|
||||
}
|
||||
|
||||
function latin1ToUint8(str: string): Uint8Array {
|
||||
const out = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function hashNameFromOid(oid: string): SigScheme['hashName'] | null {
|
||||
switch (oid) {
|
||||
case '1.3.14.3.2.26':
|
||||
return 'SHA-1';
|
||||
case '2.16.840.1.101.3.4.2.1':
|
||||
return 'SHA-256';
|
||||
case '2.16.840.1.101.3.4.2.2':
|
||||
return 'SHA-384';
|
||||
case '2.16.840.1.101.3.4.2.3':
|
||||
return 'SHA-512';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectSigScheme(
|
||||
signatureAlgorithmArr: forge.asn1.Asn1[] | undefined,
|
||||
digestOid: string
|
||||
): SigScheme | { unsupported: string } {
|
||||
if (!signatureAlgorithmArr || signatureAlgorithmArr.length === 0) {
|
||||
return { unsupported: 'Missing signatureAlgorithm' };
|
||||
}
|
||||
const oidNode = signatureAlgorithmArr[0];
|
||||
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) {
|
||||
return { unsupported: 'Malformed signatureAlgorithm' };
|
||||
}
|
||||
const oid = forge.asn1.derToOid(oidNode.value as string);
|
||||
const implicitHash = hashNameFromOid(digestOid);
|
||||
|
||||
switch (oid) {
|
||||
case '1.2.840.113549.1.1.1':
|
||||
return implicitHash
|
||||
? { kind: 'rsa-pkcs1', hashName: implicitHash }
|
||||
: { unsupported: `Unsupported digest OID ${digestOid}` };
|
||||
case '1.2.840.113549.1.1.5':
|
||||
return { kind: 'rsa-pkcs1', hashName: 'SHA-1' };
|
||||
case '1.2.840.113549.1.1.11':
|
||||
return { kind: 'rsa-pkcs1', hashName: 'SHA-256' };
|
||||
case '1.2.840.113549.1.1.12':
|
||||
return { kind: 'rsa-pkcs1', hashName: 'SHA-384' };
|
||||
case '1.2.840.113549.1.1.13':
|
||||
return { kind: 'rsa-pkcs1', hashName: 'SHA-512' };
|
||||
case '1.2.840.113549.1.1.10': {
|
||||
const params = parsePssParams(signatureAlgorithmArr[1]);
|
||||
return {
|
||||
kind: 'rsa-pss',
|
||||
hashName: params.hashName,
|
||||
pssSaltLength: params.saltLength,
|
||||
};
|
||||
}
|
||||
case '1.2.840.10045.4.1':
|
||||
return { kind: 'ecdsa', hashName: 'SHA-1' };
|
||||
case '1.2.840.10045.4.3.2':
|
||||
return { kind: 'ecdsa', hashName: 'SHA-256' };
|
||||
case '1.2.840.10045.4.3.3':
|
||||
return { kind: 'ecdsa', hashName: 'SHA-384' };
|
||||
case '1.2.840.10045.4.3.4':
|
||||
return { kind: 'ecdsa', hashName: 'SHA-512' };
|
||||
case '1.2.840.10045.2.1':
|
||||
return implicitHash
|
||||
? { kind: 'ecdsa', hashName: implicitHash }
|
||||
: { unsupported: `Unsupported digest OID ${digestOid}` };
|
||||
default:
|
||||
return { unsupported: `Unsupported signature algorithm OID ${oid}` };
|
||||
}
|
||||
}
|
||||
|
||||
function parsePssParams(paramsNode: forge.asn1.Asn1 | undefined): {
|
||||
hashName: SigScheme['hashName'];
|
||||
saltLength: number;
|
||||
} {
|
||||
const fallback = { hashName: 'SHA-1' as const, saltLength: 20 };
|
||||
if (!paramsNode || !Array.isArray(paramsNode.value)) return fallback;
|
||||
let hashName: SigScheme['hashName'] = 'SHA-1';
|
||||
let saltLength = 20;
|
||||
for (const item of paramsNode.value) {
|
||||
if (item.tagClass !== forge.asn1.Class.CONTEXT_SPECIFIC) continue;
|
||||
if (item.type === 0 && Array.isArray(item.value) && item.value[0]) {
|
||||
const algoIdSeq = item.value[0];
|
||||
if (Array.isArray(algoIdSeq.value) && algoIdSeq.value[0]) {
|
||||
const hashOid = forge.asn1.derToOid(algoIdSeq.value[0].value as string);
|
||||
const resolved = hashNameFromOid(hashOid);
|
||||
if (resolved) hashName = resolved;
|
||||
}
|
||||
} else if (item.type === 2 && typeof item.value === 'string') {
|
||||
let n = 0;
|
||||
for (let i = 0; i < item.value.length; i++) {
|
||||
n = (n << 8) | item.value.charCodeAt(i);
|
||||
}
|
||||
if (n > 0 && n < 1024) saltLength = n;
|
||||
}
|
||||
}
|
||||
return { hashName, saltLength };
|
||||
}
|
||||
|
||||
function extractSpkiDer(
|
||||
p7: forge.pkcs7.PkcsSignedData & {
|
||||
rawCapture?: { certificates?: forge.asn1.Asn1 };
|
||||
}
|
||||
): Uint8Array | null {
|
||||
try {
|
||||
const certsNode = p7.rawCapture?.certificates;
|
||||
if (!certsNode || !Array.isArray(certsNode.value) || !certsNode.value[0]) {
|
||||
return null;
|
||||
}
|
||||
const certAsn1 = certsNode.value[0];
|
||||
if (!Array.isArray(certAsn1.value) || !certAsn1.value[0]) return null;
|
||||
const tbs = certAsn1.value[0];
|
||||
if (!Array.isArray(tbs.value)) return null;
|
||||
let startIdx = 0;
|
||||
if (
|
||||
tbs.value[0] &&
|
||||
tbs.value[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC
|
||||
) {
|
||||
startIdx = 1;
|
||||
}
|
||||
const spkiAsn1 = tbs.value[startIdx + 5];
|
||||
if (!spkiAsn1) return null;
|
||||
return latin1ToUint8(forge.asn1.toDer(spkiAsn1).getBytes());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function curveFromSpki(
|
||||
spkiDer: Uint8Array
|
||||
): { name: 'P-256' | 'P-384' | 'P-521'; coordBytes: number } | null {
|
||||
try {
|
||||
const spki = forge.asn1.fromDer(uint8ToLatin1(spkiDer));
|
||||
if (!Array.isArray(spki.value) || !spki.value[0]) return null;
|
||||
const algoId = spki.value[0];
|
||||
if (!Array.isArray(algoId.value) || !algoId.value[1]) return null;
|
||||
const params = algoId.value[1];
|
||||
if (params.type !== forge.asn1.Type.OID) return null;
|
||||
const oid = forge.asn1.derToOid(params.value as string);
|
||||
if (oid === '1.2.840.10045.3.1.7') return { name: 'P-256', coordBytes: 32 };
|
||||
if (oid === '1.3.132.0.34') return { name: 'P-384', coordBytes: 48 };
|
||||
if (oid === '1.3.132.0.35') return { name: 'P-521', coordBytes: 66 };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ecdsaDerToP1363(
|
||||
derSig: Uint8Array,
|
||||
coordBytes: number
|
||||
): Uint8Array | null {
|
||||
try {
|
||||
const parsed = forge.asn1.fromDer(uint8ToLatin1(derSig));
|
||||
if (!Array.isArray(parsed.value) || parsed.value.length !== 2) return null;
|
||||
const r = latin1ToUint8(parsed.value[0].value as string);
|
||||
const s = latin1ToUint8(parsed.value[1].value as string);
|
||||
const rStripped = r[0] === 0 && r.length > 1 ? r.slice(1) : r;
|
||||
const sStripped = s[0] === 0 && s.length > 1 ? s.slice(1) : s;
|
||||
if (rStripped.length > coordBytes || sStripped.length > coordBytes) {
|
||||
return null;
|
||||
}
|
||||
const out = new Uint8Array(coordBytes * 2);
|
||||
out.set(rStripped, coordBytes - rStripped.length);
|
||||
out.set(sStripped, coordBytes * 2 - sStripped.length);
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyViaWebCrypto(
|
||||
scheme: SigScheme,
|
||||
spkiDer: Uint8Array,
|
||||
signedBytes: Uint8Array,
|
||||
signatureBytes: Uint8Array
|
||||
): Promise<CryptoVerificationResult> {
|
||||
const subtle =
|
||||
typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle
|
||||
? globalThis.crypto.subtle
|
||||
: null;
|
||||
if (!subtle) {
|
||||
return {
|
||||
status: 'unsupported',
|
||||
reason: 'Web Crypto API not available in this context',
|
||||
};
|
||||
}
|
||||
|
||||
const spki = new Uint8Array(spkiDer);
|
||||
const signed = new Uint8Array(signedBytes);
|
||||
const sig = new Uint8Array(signatureBytes);
|
||||
|
||||
try {
|
||||
if (scheme.kind === 'rsa-pss') {
|
||||
const key = await subtle.importKey(
|
||||
'spki',
|
||||
spki,
|
||||
{ name: 'RSA-PSS', hash: scheme.hashName },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const ok = await subtle.verify(
|
||||
{ name: 'RSA-PSS', saltLength: scheme.pssSaltLength ?? 32 },
|
||||
key,
|
||||
sig,
|
||||
signed
|
||||
);
|
||||
return ok
|
||||
? { status: 'verified' }
|
||||
: {
|
||||
status: 'failed',
|
||||
reason:
|
||||
'RSA-PSS signature does not verify against signer public key',
|
||||
};
|
||||
}
|
||||
|
||||
if (scheme.kind === 'ecdsa') {
|
||||
const curve = curveFromSpki(spki);
|
||||
if (!curve) {
|
||||
return {
|
||||
status: 'unsupported',
|
||||
reason: 'Unsupported ECDSA curve in signer certificate',
|
||||
};
|
||||
}
|
||||
const p1363 = ecdsaDerToP1363(sig, curve.coordBytes);
|
||||
if (!p1363) {
|
||||
return {
|
||||
status: 'failed',
|
||||
reason: 'Malformed ECDSA signature (could not parse r,s)',
|
||||
};
|
||||
}
|
||||
const key = await subtle.importKey(
|
||||
'spki',
|
||||
spki,
|
||||
{ name: 'ECDSA', namedCurve: curve.name },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const ok = await subtle.verify(
|
||||
{ name: 'ECDSA', hash: scheme.hashName },
|
||||
key,
|
||||
new Uint8Array(p1363),
|
||||
signed
|
||||
);
|
||||
return ok
|
||||
? { status: 'verified' }
|
||||
: {
|
||||
status: 'failed',
|
||||
reason: 'ECDSA signature does not verify against signer public key',
|
||||
};
|
||||
}
|
||||
|
||||
if (scheme.kind === 'rsa-pkcs1') {
|
||||
const key = await subtle.importKey(
|
||||
'spki',
|
||||
spki,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: scheme.hashName },
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
const ok = await subtle.verify('RSASSA-PKCS1-v1_5', key, sig, signed);
|
||||
return ok
|
||||
? { status: 'verified' }
|
||||
: {
|
||||
status: 'failed',
|
||||
reason:
|
||||
'RSA-PKCS1 signature does not verify against signer public key',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unsupported',
|
||||
reason: `Signature scheme ${scheme.kind} not implemented`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'unsupported',
|
||||
reason:
|
||||
'Web Crypto import/verify failed: ' +
|
||||
(e instanceof Error ? e.message : String(e)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function performCryptoVerification(
|
||||
p7: forge.pkcs7.PkcsSignedData & {
|
||||
rawCapture?: {
|
||||
signatureAlgorithm?: forge.asn1.Asn1[];
|
||||
certificates?: forge.asn1.Asn1;
|
||||
};
|
||||
},
|
||||
pdfBytes: Uint8Array,
|
||||
byteRange: number[],
|
||||
signerCert: forge.pki.Certificate,
|
||||
fields: SignerInfoFields | null
|
||||
): Promise<CryptoVerificationResult> {
|
||||
if (!fields) {
|
||||
return { status: 'failed', reason: 'Could not parse signer info' };
|
||||
}
|
||||
if (byteRange.length !== 4) {
|
||||
return { status: 'failed', reason: 'Malformed ByteRange' };
|
||||
}
|
||||
|
||||
const md = createMd(fields.digestOid);
|
||||
if (!md) {
|
||||
return {
|
||||
status: 'unsupported',
|
||||
reason: `Unsupported digest OID ${fields.digestOid}`,
|
||||
};
|
||||
}
|
||||
|
||||
const [start1, len1, start2, len2] = byteRange;
|
||||
if (
|
||||
start1 < 0 ||
|
||||
len1 < 0 ||
|
||||
start2 < 0 ||
|
||||
len2 < 0 ||
|
||||
start1 + len1 > pdfBytes.length ||
|
||||
start2 + len2 > pdfBytes.length
|
||||
) {
|
||||
return { status: 'failed', reason: 'ByteRange out of bounds' };
|
||||
}
|
||||
|
||||
const signedContent = new Uint8Array(len1 + len2);
|
||||
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
|
||||
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
|
||||
|
||||
md.update(uint8ToLatin1(signedContent));
|
||||
const contentHashBytes = md.digest().bytes();
|
||||
|
||||
const authAttrs = fields.authAttrs;
|
||||
const signatureBytes = fields.signatureBytes;
|
||||
if (!signatureBytes) {
|
||||
return { status: 'failed', reason: 'Empty signature bytes' };
|
||||
}
|
||||
|
||||
const scheme = detectSigScheme(
|
||||
p7.rawCapture?.signatureAlgorithm,
|
||||
fields.digestOid
|
||||
);
|
||||
if ('unsupported' in scheme) {
|
||||
return { status: 'unsupported', reason: scheme.unsupported };
|
||||
}
|
||||
|
||||
let messageDigestAttrValue: string | null = null;
|
||||
let signedBytesForVerify: Uint8Array;
|
||||
|
||||
if (authAttrs) {
|
||||
for (const attr of authAttrs) {
|
||||
if (!attr.value || !Array.isArray(attr.value) || attr.value.length < 2)
|
||||
continue;
|
||||
const oidNode = attr.value[0];
|
||||
const setNode = attr.value[1];
|
||||
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
|
||||
const oid = forge.asn1.derToOid(oidNode.value as string);
|
||||
if (oid === forge.pki.oids.messageDigest) {
|
||||
if (
|
||||
setNode?.value &&
|
||||
Array.isArray(setNode.value) &&
|
||||
setNode.value[0]
|
||||
) {
|
||||
messageDigestAttrValue = setNode.value[0].value as string;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (messageDigestAttrValue === null) {
|
||||
return {
|
||||
status: 'failed',
|
||||
reason: 'messageDigest attribute missing from authenticated attributes',
|
||||
};
|
||||
}
|
||||
if (messageDigestAttrValue !== contentHashBytes) {
|
||||
return {
|
||||
status: 'failed',
|
||||
reason:
|
||||
'Content hash does not match messageDigest attribute — PDF was modified after signing',
|
||||
};
|
||||
}
|
||||
|
||||
const asSet = forge.asn1.create(
|
||||
forge.asn1.Class.UNIVERSAL,
|
||||
forge.asn1.Type.SET,
|
||||
true,
|
||||
authAttrs
|
||||
);
|
||||
signedBytesForVerify = latin1ToUint8(forge.asn1.toDer(asSet).getBytes());
|
||||
} else {
|
||||
signedBytesForVerify = signedContent;
|
||||
}
|
||||
|
||||
if (scheme.kind === 'rsa-pkcs1') {
|
||||
try {
|
||||
const publicKey = signerCert.publicKey as forge.pki.rsa.PublicKey;
|
||||
const md2 = createMd(fields.digestOid)!;
|
||||
md2.update(uint8ToLatin1(signedBytesForVerify));
|
||||
const ok = publicKey.verify(md2.digest().bytes(), signatureBytes);
|
||||
if (ok) return { status: 'verified' };
|
||||
} catch {
|
||||
// fall through to Web Crypto
|
||||
}
|
||||
}
|
||||
|
||||
const spkiDer = extractSpkiDer(p7);
|
||||
if (!spkiDer) {
|
||||
return {
|
||||
status: 'unsupported',
|
||||
reason: 'Could not extract signer public key',
|
||||
};
|
||||
}
|
||||
return verifyViaWebCrypto(
|
||||
scheme,
|
||||
spkiDer,
|
||||
signedBytesForVerify,
|
||||
latin1ToUint8(signatureBytes)
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { WasmProvider, type WasmPackage } from '../utils/wasm-provider.js';
|
||||
import { clearPyMuPDFCache } from '../utils/pymupdf-loader.js';
|
||||
import { clearGhostscriptCache } from '../utils/ghostscript-dynamic-loader.js';
|
||||
import { initI18n, t } from '../i18n/index.js';
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
@@ -10,9 +11,15 @@ if (document.readyState === 'loading') {
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
async function initializePage() {
|
||||
createIcons({ icons });
|
||||
|
||||
try {
|
||||
await initI18n();
|
||||
} catch (error) {
|
||||
console.warn('WASM settings i18n initialization failed:', error);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.copy-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const url = btn.getAttribute('data-copy');
|
||||
@@ -90,7 +97,7 @@ function initializePage() {
|
||||
input.value = url;
|
||||
} else {
|
||||
input.value = '';
|
||||
input.placeholder = `Using default: ${url}`;
|
||||
input.placeholder = t('tools:wasmSettings.usingDefault', { url });
|
||||
}
|
||||
updateStatus(name, true);
|
||||
}
|
||||
@@ -111,19 +118,19 @@ function initializePage() {
|
||||
if (!statusEl) return;
|
||||
|
||||
if (testing) {
|
||||
statusEl.textContent = 'Testing...';
|
||||
statusEl.textContent = t('tools:wasmSettings.status.testing');
|
||||
statusEl.className =
|
||||
'text-xs px-2 py-1 rounded-full bg-yellow-600/30 text-yellow-300';
|
||||
} else if (configured && WasmProvider.isUserConfigured(packageName)) {
|
||||
statusEl.textContent = 'Custom Override';
|
||||
statusEl.textContent = t('tools:wasmSettings.status.customOverride');
|
||||
statusEl.className =
|
||||
'text-xs px-2 py-1 rounded-full bg-blue-600/30 text-blue-300';
|
||||
} else if (configured || WasmProvider.hasEnvDefault(packageName)) {
|
||||
statusEl.textContent = 'Pre-configured';
|
||||
statusEl.textContent = t('tools:wasmSettings.status.preConfigured');
|
||||
statusEl.className =
|
||||
'text-xs px-2 py-1 rounded-full bg-green-600/30 text-green-300';
|
||||
} else {
|
||||
statusEl.textContent = 'Not Configured';
|
||||
statusEl.textContent = t('tools:wasmSettings.status.notConfigured');
|
||||
statusEl.className =
|
||||
'text-xs px-2 py-1 rounded-full bg-gray-600 text-gray-300';
|
||||
}
|
||||
@@ -131,7 +138,10 @@ function initializePage() {
|
||||
|
||||
async function testConnection(packageName: WasmPackage, url: string) {
|
||||
if (!url.trim()) {
|
||||
showAlert('Empty URL', 'Please enter a URL to test.');
|
||||
showAlert(
|
||||
t('tools:wasmSettings.emptyUrlTitle'),
|
||||
t('tools:wasmSettings.emptyUrlMessage')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,15 +152,17 @@ function initializePage() {
|
||||
if (result.valid) {
|
||||
updateStatus(packageName, true);
|
||||
showAlert(
|
||||
'Success',
|
||||
`Connection to ${WasmProvider.getPackageDisplayName(packageName)} successful!`,
|
||||
t('tools:wasmSettings.testSuccessTitle'),
|
||||
t('tools:wasmSettings.connectionSuccess', {
|
||||
package: WasmProvider.getPackageDisplayName(packageName),
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
updateStatus(packageName, false);
|
||||
showAlert(
|
||||
'Connection Failed',
|
||||
result.error || 'Could not connect to the URL.'
|
||||
t('tools:wasmSettings.connectionFailedTitle'),
|
||||
result.error || t('tools:wasmSettings.connectionFailedMessage')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -168,7 +180,7 @@ function initializePage() {
|
||||
});
|
||||
|
||||
saveBtn?.addEventListener('click', async () => {
|
||||
showLoader('Saving configuration...');
|
||||
showLoader(t('tools:wasmSettings.savingConfiguration'));
|
||||
|
||||
try {
|
||||
if (pymupdfUrl.value.trim()) {
|
||||
@@ -196,11 +208,19 @@ function initializePage() {
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
showAlert('Saved', 'Configuration saved successfully!', 'success');
|
||||
showAlert(
|
||||
t('tools:wasmSettings.savedTitle'),
|
||||
t('tools:wasmSettings.savedMessage'),
|
||||
'success'
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
hideLoader();
|
||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
||||
showAlert('Error', `Failed to save configuration: ${errorMessage}`);
|
||||
const errorMessage =
|
||||
e instanceof Error ? e.message : t('common.unknownError');
|
||||
showAlert(
|
||||
t('tools:wasmSettings.errorTitle'),
|
||||
t('tools:wasmSettings.failedSave', { message: errorMessage })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -225,10 +245,10 @@ function initializePage() {
|
||||
WasmProvider.hasEnvDefault('cpdf');
|
||||
|
||||
showAlert(
|
||||
'Reset',
|
||||
t('tools:wasmSettings.resetTitle'),
|
||||
hasDefaults
|
||||
? 'Custom overrides cleared. Pre-configured defaults are active.'
|
||||
: 'All configurations and cached modules have been cleared.',
|
||||
? t('tools:wasmSettings.resetWithDefaults')
|
||||
: t('tools:wasmSettings.resetNoDefaults'),
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
+15
-11
@@ -1,4 +1,5 @@
|
||||
// NOTE: This is a work in progress and does not work correctly as of yet
|
||||
import DOMPurify from 'dompurify';
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
@@ -41,17 +42,20 @@ export async function wordToPdf() {
|
||||
const downloadBtn = document.getElementById('preview-download-btn');
|
||||
const closeBtn = document.getElementById('preview-close-btn');
|
||||
|
||||
const styledHtml = `
|
||||
<style>
|
||||
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
|
||||
#preview-content table { border-collapse: collapse; width: 100%; }
|
||||
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
|
||||
#preview-content img { max-width: 100%; height: auto; }
|
||||
#preview-content a { color: #0000ee; text-decoration: underline; }
|
||||
</style>
|
||||
${html}
|
||||
`;
|
||||
previewContent.innerHTML = styledHtml;
|
||||
const STYLE_ID = 'word-to-pdf-preview-style';
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = STYLE_ID;
|
||||
styleEl.textContent = `
|
||||
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
|
||||
#preview-content table { border-collapse: collapse; width: 100%; }
|
||||
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
|
||||
#preview-content img { max-width: 100%; height: auto; }
|
||||
#preview-content a { color: #0000ee; text-decoration: underline; }
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
previewContent.innerHTML = DOMPurify.sanitize(html);
|
||||
|
||||
const marginDiv = document.createElement('div');
|
||||
marginDiv.style.height = '100px';
|
||||
|
||||
Reference in new issue
Block a user