mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-13 09:54:19 +01:00
Add visual workflow builder, fix critical bugs, and add Arabic i18n support
This commit is contained in:
1 parent
36ebb3b429
commit
5d8b83e105
118 files changed
+14151
-2357
No files matched your search
@@ -3,6 +3,13 @@ export const categories = [
|
||||
{
|
||||
name: 'Popular Tools',
|
||||
tools: [
|
||||
{
|
||||
href: import.meta.env.BASE_URL + 'pdf-workflow.html',
|
||||
name: 'PDF Workflow Builder',
|
||||
icon: 'ph-tree-structure',
|
||||
subtitle:
|
||||
'Build custom PDF processing pipelines with a visual node editor.',
|
||||
},
|
||||
{
|
||||
href: import.meta.env.BASE_URL + 'pdf-multi-tool.html',
|
||||
name: 'PDF Multi Tool',
|
||||
|
||||
+6
-3
@@ -4,6 +4,7 @@ import HttpBackend from 'i18next-http-backend';
|
||||
// Supported languages
|
||||
export const supportedLanguages = [
|
||||
'en',
|
||||
'ar',
|
||||
'be',
|
||||
'fr',
|
||||
'de',
|
||||
@@ -22,6 +23,7 @@ export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const languageNames: Record<SupportedLanguage, string> = {
|
||||
en: 'English',
|
||||
ar: 'العربية',
|
||||
be: 'Беларуская',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
@@ -50,7 +52,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
|
||||
}
|
||||
|
||||
const langMatch = path.match(
|
||||
/^\/(en|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da)(?:\/|$)/
|
||||
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da)(?:\/|$)/
|
||||
);
|
||||
if (
|
||||
langMatch &&
|
||||
@@ -121,7 +123,7 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
|
||||
|
||||
let pagePathWithoutLang = relativePath;
|
||||
const langPrefixMatch = relativePath.match(
|
||||
/^\/(en|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be)(\/.*)?$/
|
||||
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da)(\/.*)?$/
|
||||
);
|
||||
if (langPrefixMatch) {
|
||||
pagePathWithoutLang = langPrefixMatch[2] || '/';
|
||||
@@ -184,6 +186,7 @@ export const applyTranslations = (): void => {
|
||||
});
|
||||
|
||||
document.documentElement.lang = i18next.language;
|
||||
document.documentElement.dir = i18next.language === 'ar' ? 'rtl' : 'ltr';
|
||||
};
|
||||
|
||||
export const rewriteLinks = (): void => {
|
||||
@@ -213,7 +216,7 @@ export const rewriteLinks = (): void => {
|
||||
}
|
||||
|
||||
const langPrefixRegex = new RegExp(
|
||||
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da)(/|$)`
|
||||
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da)(/|$)`
|
||||
);
|
||||
if (langPrefixRegex.test(href)) {
|
||||
return;
|
||||
|
||||
+234
-173
@@ -1,216 +1,277 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { downloadFile, hexToRgb, formatBytes, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import { PDFDocument as PDFLibDocument, rgb, degrees, StandardFonts } from 'pdf-lib';
|
||||
import {
|
||||
downloadFile,
|
||||
hexToRgb,
|
||||
formatBytes,
|
||||
readFileAsArrayBuffer,
|
||||
} from '../utils/helpers.js';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import {
|
||||
addTextWatermark,
|
||||
addImageWatermark,
|
||||
} from '../utils/pdf-operations.js';
|
||||
import { AddWatermarkState } from '@/types';
|
||||
|
||||
const pageState: AddWatermarkState = { file: null, pdfDoc: null };
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
fileInput.addEventListener('click', () => { fileInput.value = ''; });
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('border-indigo-500'); });
|
||||
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('border-indigo-500'); });
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-indigo-500');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
if (backBtn) backBtn.addEventListener('click', () => { window.location.href = import.meta.env.BASE_URL; });
|
||||
if (processBtn) processBtn.addEventListener('click', addWatermark);
|
||||
if (backBtn)
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
if (processBtn) processBtn.addEventListener('click', addWatermark);
|
||||
|
||||
setupWatermarkUI();
|
||||
setupWatermarkUI();
|
||||
}
|
||||
|
||||
function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) handleFiles(input.files);
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) handleFiles(input.files);
|
||||
}
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
const file = files[0];
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Invalid File', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
pageState.file = file;
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
const file = files[0];
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Invalid File', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
pageState.file = file;
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileDisplay() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return;
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = resetState;
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return;
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = resetState;
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
function setupWatermarkUI() {
|
||||
const watermarkTypeRadios = document.querySelectorAll('input[name="watermark-type"]');
|
||||
const textOptions = document.getElementById('text-watermark-options');
|
||||
const imageOptions = document.getElementById('image-watermark-options');
|
||||
const watermarkTypeRadios = document.querySelectorAll(
|
||||
'input[name="watermark-type"]'
|
||||
);
|
||||
const textOptions = document.getElementById('text-watermark-options');
|
||||
const imageOptions = document.getElementById('image-watermark-options');
|
||||
|
||||
watermarkTypeRadios.forEach((radio) => {
|
||||
radio.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value === 'text') {
|
||||
textOptions?.classList.remove('hidden');
|
||||
imageOptions?.classList.add('hidden');
|
||||
} else {
|
||||
textOptions?.classList.add('hidden');
|
||||
imageOptions?.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
watermarkTypeRadios.forEach((radio) => {
|
||||
radio.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value === 'text') {
|
||||
textOptions?.classList.remove('hidden');
|
||||
imageOptions?.classList.add('hidden');
|
||||
} else {
|
||||
textOptions?.classList.add('hidden');
|
||||
imageOptions?.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const opacitySliderText = document.getElementById('opacity-text') as HTMLInputElement;
|
||||
const opacityValueText = document.getElementById('opacity-value-text');
|
||||
const angleSliderText = document.getElementById('angle-text') as HTMLInputElement;
|
||||
const angleValueText = document.getElementById('angle-value-text');
|
||||
const opacitySliderText = document.getElementById(
|
||||
'opacity-text'
|
||||
) as HTMLInputElement;
|
||||
const opacityValueText = document.getElementById('opacity-value-text');
|
||||
const angleSliderText = document.getElementById(
|
||||
'angle-text'
|
||||
) as HTMLInputElement;
|
||||
const angleValueText = document.getElementById('angle-value-text');
|
||||
|
||||
opacitySliderText?.addEventListener('input', () => { if (opacityValueText) opacityValueText.textContent = opacitySliderText.value; });
|
||||
angleSliderText?.addEventListener('input', () => { if (angleValueText) angleValueText.textContent = angleSliderText.value; });
|
||||
opacitySliderText?.addEventListener('input', () => {
|
||||
if (opacityValueText)
|
||||
opacityValueText.textContent = opacitySliderText.value;
|
||||
});
|
||||
angleSliderText?.addEventListener('input', () => {
|
||||
if (angleValueText) angleValueText.textContent = angleSliderText.value;
|
||||
});
|
||||
|
||||
const opacitySliderImage = document.getElementById('opacity-image') as HTMLInputElement;
|
||||
const opacityValueImage = document.getElementById('opacity-value-image');
|
||||
const angleSliderImage = document.getElementById('angle-image') as HTMLInputElement;
|
||||
const angleValueImage = document.getElementById('angle-value-image');
|
||||
const opacitySliderImage = document.getElementById(
|
||||
'opacity-image'
|
||||
) as HTMLInputElement;
|
||||
const opacityValueImage = document.getElementById('opacity-value-image');
|
||||
const angleSliderImage = document.getElementById(
|
||||
'angle-image'
|
||||
) as HTMLInputElement;
|
||||
const angleValueImage = document.getElementById('angle-value-image');
|
||||
|
||||
opacitySliderImage?.addEventListener('input', () => { if (opacityValueImage) opacityValueImage.textContent = opacitySliderImage.value; });
|
||||
angleSliderImage?.addEventListener('input', () => { if (angleValueImage) angleValueImage.textContent = angleSliderImage.value; });
|
||||
opacitySliderImage?.addEventListener('input', () => {
|
||||
if (opacityValueImage)
|
||||
opacityValueImage.textContent = opacitySliderImage.value;
|
||||
});
|
||||
angleSliderImage?.addEventListener('input', () => {
|
||||
if (angleValueImage) angleValueImage.textContent = angleSliderImage.value;
|
||||
});
|
||||
}
|
||||
|
||||
async function addWatermark() {
|
||||
if (!pageState.pdfDoc) {
|
||||
showAlert('Error', 'Please upload a PDF file first.');
|
||||
return;
|
||||
if (!pageState.pdfDoc) {
|
||||
showAlert('Error', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const watermarkType =
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="watermark-type"]:checked'
|
||||
) as HTMLInputElement
|
||||
)?.value || 'text';
|
||||
showLoader('Adding watermark...');
|
||||
|
||||
try {
|
||||
const pdfBytes = new Uint8Array(await pageState.pdfDoc.save());
|
||||
let resultBytes: Uint8Array;
|
||||
|
||||
if (watermarkType === 'text') {
|
||||
const text = (
|
||||
document.getElementById('watermark-text') as HTMLInputElement
|
||||
).value;
|
||||
if (!text.trim()) throw new Error('Please enter text for the watermark.');
|
||||
const fontSize =
|
||||
parseInt(
|
||||
(document.getElementById('font-size') as HTMLInputElement).value
|
||||
) || 72;
|
||||
const angle =
|
||||
parseInt(
|
||||
(document.getElementById('angle-text') as HTMLInputElement).value
|
||||
) || 0;
|
||||
const opacity =
|
||||
parseFloat(
|
||||
(document.getElementById('opacity-text') as HTMLInputElement).value
|
||||
) || 0.3;
|
||||
const colorHex = (
|
||||
document.getElementById('text-color') as HTMLInputElement
|
||||
).value;
|
||||
const textColor = hexToRgb(colorHex);
|
||||
|
||||
resultBytes = await addTextWatermark(pdfBytes, {
|
||||
text,
|
||||
fontSize,
|
||||
color: textColor,
|
||||
opacity,
|
||||
angle,
|
||||
});
|
||||
} else {
|
||||
const imageFile = (
|
||||
document.getElementById('image-watermark-input') as HTMLInputElement
|
||||
).files?.[0];
|
||||
if (!imageFile)
|
||||
throw new Error('Please select an image file for the watermark.');
|
||||
const imageBytes = await readFileAsArrayBuffer(imageFile);
|
||||
const angle =
|
||||
parseInt(
|
||||
(document.getElementById('angle-image') as HTMLInputElement).value
|
||||
) || 0;
|
||||
const opacity =
|
||||
parseFloat(
|
||||
(document.getElementById('opacity-image') as HTMLInputElement).value
|
||||
) || 0.3;
|
||||
|
||||
let imageType: 'png' | 'jpg';
|
||||
if (imageFile.type === 'image/png') {
|
||||
imageType = 'png';
|
||||
} else if (imageFile.type === 'image/jpeg') {
|
||||
imageType = 'jpg';
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unsupported Image. Please use a PNG or JPG for the watermark.'
|
||||
);
|
||||
}
|
||||
|
||||
resultBytes = await addImageWatermark(pdfBytes, {
|
||||
imageBytes: new Uint8Array(imageBytes as ArrayBuffer),
|
||||
imageType,
|
||||
opacity,
|
||||
angle,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
const watermarkType = (document.querySelector('input[name="watermark-type"]:checked') as HTMLInputElement)?.value || 'text';
|
||||
showLoader('Adding watermark...');
|
||||
|
||||
try {
|
||||
const pages = pageState.pdfDoc.getPages();
|
||||
let watermarkAsset: any = null;
|
||||
|
||||
if (watermarkType === 'text') {
|
||||
watermarkAsset = await pageState.pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
} else {
|
||||
const imageFile = (document.getElementById('image-watermark-input') as HTMLInputElement).files?.[0];
|
||||
if (!imageFile) throw new Error('Please select an image file for the watermark.');
|
||||
const imageBytes = await readFileAsArrayBuffer(imageFile);
|
||||
if (imageFile.type === 'image/png') {
|
||||
watermarkAsset = await pageState.pdfDoc.embedPng(imageBytes as ArrayBuffer);
|
||||
} else if (imageFile.type === 'image/jpeg') {
|
||||
watermarkAsset = await pageState.pdfDoc.embedJpg(imageBytes as ArrayBuffer);
|
||||
} else {
|
||||
throw new Error('Unsupported Image. Please use a PNG or JPG for the watermark.');
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
if (watermarkType === 'text') {
|
||||
const text = (document.getElementById('watermark-text') as HTMLInputElement).value;
|
||||
if (!text.trim()) throw new Error('Please enter text for the watermark.');
|
||||
const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement).value) || 72;
|
||||
const angle = parseInt((document.getElementById('angle-text') as HTMLInputElement).value) || 0;
|
||||
const opacity = parseFloat((document.getElementById('opacity-text') as HTMLInputElement).value) || 0.3;
|
||||
const colorHex = (document.getElementById('text-color') as HTMLInputElement).value;
|
||||
const textColor = hexToRgb(colorHex);
|
||||
const textWidth = watermarkAsset.widthOfTextAtSize(text, fontSize);
|
||||
|
||||
page.drawText(text, {
|
||||
x: (width / 2) - ((textWidth / 2) * Math.cos(angle * Math.PI / 180)),
|
||||
y: (height / 2) - ((textWidth / 2) * Math.sin(angle * Math.PI / 180)),
|
||||
font: watermarkAsset,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
opacity: opacity,
|
||||
rotate: degrees(angle),
|
||||
});
|
||||
} else {
|
||||
const angle = parseInt((document.getElementById('angle-image') as HTMLInputElement).value) || 0;
|
||||
const opacity = parseFloat((document.getElementById('opacity-image') as HTMLInputElement).value) || 0.3;
|
||||
const scale = 0.5;
|
||||
const imgWidth = watermarkAsset.width * scale;
|
||||
const imgHeight = watermarkAsset.height * scale;
|
||||
|
||||
page.drawImage(watermarkAsset, {
|
||||
x: (width - imgWidth) / 2,
|
||||
y: (height - imgHeight) / 2,
|
||||
width: imgWidth,
|
||||
height: imgHeight,
|
||||
opacity: opacity,
|
||||
rotate: degrees(angle),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newPdfBytes = await pageState.pdfDoc.save();
|
||||
downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'watermarked.pdf');
|
||||
showAlert('Success', 'Watermark added successfully!', 'success', () => { resetState(); });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
showAlert('Error', e.message || 'Could not add the watermark.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
downloadFile(
|
||||
new Blob([resultBytes.buffer as ArrayBuffer], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
'watermarked.pdf'
|
||||
);
|
||||
showAlert('Success', 'Watermark added successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
showAlert('Error', e.message || 'Could not add the watermark.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../utils/helpers.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { applyColorAdjustments } from '../utils/image-effects.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { AdjustColorsSettings } from '../types/adjust-colors-type.js';
|
||||
|
||||
@@ -58,155 +59,7 @@ function getSettings(): AdjustColorsSettings {
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255);
|
||||
return [v, v, v];
|
||||
}
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return [
|
||||
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
Math.round(hue2rgb(p, q, h) * 255),
|
||||
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
function applyEffects(
|
||||
sourceData: ImageData,
|
||||
canvas: HTMLCanvasElement,
|
||||
settings: AdjustColorsSettings
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const w = sourceData.width;
|
||||
const h = sourceData.height;
|
||||
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
|
||||
const imageData = new ImageData(new Uint8ClampedArray(sourceData.data), w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
const contrastFactor =
|
||||
settings.contrast !== 0
|
||||
? (259 * (settings.contrast + 255)) / (255 * (259 - settings.contrast))
|
||||
: 1;
|
||||
|
||||
const gammaCorrection = settings.gamma !== 1.0 ? 1 / settings.gamma : 1;
|
||||
const sepiaAmount = settings.sepia / 100;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
|
||||
// Brightness
|
||||
if (settings.brightness !== 0) {
|
||||
const adj = settings.brightness * 2.55;
|
||||
r += adj;
|
||||
g += adj;
|
||||
b += adj;
|
||||
}
|
||||
|
||||
// Contrast
|
||||
if (settings.contrast !== 0) {
|
||||
r = contrastFactor * (r - 128) + 128;
|
||||
g = contrastFactor * (g - 128) + 128;
|
||||
b = contrastFactor * (b - 128) + 128;
|
||||
}
|
||||
|
||||
// Saturation and Hue Shift (via HSL)
|
||||
if (settings.saturation !== 0 || settings.hueShift !== 0) {
|
||||
const [hue, sat, lig] = rgbToHsl(
|
||||
Math.max(0, Math.min(255, r)),
|
||||
Math.max(0, Math.min(255, g)),
|
||||
Math.max(0, Math.min(255, b))
|
||||
);
|
||||
|
||||
let newHue = hue;
|
||||
if (settings.hueShift !== 0) {
|
||||
newHue = (hue + settings.hueShift / 360) % 1;
|
||||
if (newHue < 0) newHue += 1;
|
||||
}
|
||||
|
||||
let newSat = sat;
|
||||
if (settings.saturation !== 0) {
|
||||
const satAdj = settings.saturation / 100;
|
||||
newSat = satAdj > 0 ? sat + (1 - sat) * satAdj : sat * (1 + satAdj);
|
||||
newSat = Math.max(0, Math.min(1, newSat));
|
||||
}
|
||||
|
||||
[r, g, b] = hslToRgb(newHue, newSat, lig);
|
||||
}
|
||||
|
||||
// Temperature (warm/cool shift)
|
||||
if (settings.temperature !== 0) {
|
||||
const t = settings.temperature / 50;
|
||||
r += 30 * t;
|
||||
b -= 30 * t;
|
||||
}
|
||||
|
||||
// Tint (green-magenta axis)
|
||||
if (settings.tint !== 0) {
|
||||
const t = settings.tint / 50;
|
||||
g += 30 * t;
|
||||
}
|
||||
|
||||
// Gamma
|
||||
if (settings.gamma !== 1.0) {
|
||||
r = Math.pow(Math.max(0, Math.min(255, r)) / 255, gammaCorrection) * 255;
|
||||
g = Math.pow(Math.max(0, Math.min(255, g)) / 255, gammaCorrection) * 255;
|
||||
b = Math.pow(Math.max(0, Math.min(255, b)) / 255, gammaCorrection) * 255;
|
||||
}
|
||||
|
||||
// Sepia
|
||||
if (settings.sepia > 0) {
|
||||
const sr = 0.393 * r + 0.769 * g + 0.189 * b;
|
||||
const sg = 0.349 * r + 0.686 * g + 0.168 * b;
|
||||
const sb = 0.272 * r + 0.534 * g + 0.131 * b;
|
||||
r = r + (sr - r) * sepiaAmount;
|
||||
g = g + (sg - g) * sepiaAmount;
|
||||
b = b + (sb - b) * sepiaAmount;
|
||||
}
|
||||
|
||||
data[i] = Math.max(0, Math.min(255, r));
|
||||
data[i + 1] = Math.max(0, Math.min(255, g));
|
||||
data[i + 2] = Math.max(0, Math.min(255, b));
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
const applyEffects = applyColorAdjustments;
|
||||
|
||||
function updatePreview(): void {
|
||||
if (!cachedBaselineData) return;
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { state } from '../state.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
|
||||
import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
@@ -121,15 +120,6 @@ async function performCondenseCompression(
|
||||
return { ...result, usedFallback: true };
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes('closed or encrypted') ||
|
||||
errorMessage.includes('encrypted')
|
||||
) {
|
||||
throw new Error(
|
||||
'This PDF is encrypted or password-protected. Please remove the password first using the Unlock PDF tool, then try compressing again.'
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`PDF compression failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
+231
-189
@@ -1,267 +1,309 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument, parsePageRanges } from '../utils/helpers.js';
|
||||
import {
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
downloadFile,
|
||||
getPDFDocument,
|
||||
parsePageRanges,
|
||||
} from '../utils/helpers.js';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { deletePdfPages } from '../utils/pdf-operations.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { DeletePagesState } from '@/types';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const deleteState: DeletePagesState = {
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
pdfJsDoc: null,
|
||||
totalPages: 0,
|
||||
pagesToDelete: new Set(),
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
pdfJsDoc: null,
|
||||
totalPages: 0,
|
||||
pagesToDelete: new Set(),
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const pagesInput = document.getElementById(
|
||||
'pages-to-delete'
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (fileInput) fileInput.addEventListener('change', handleFileUpload);
|
||||
if (fileInput) fileInput.addEventListener('change', handleFileUpload);
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
|
||||
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('bg-gray-700'); });
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) handleFile(droppedFiles[0]);
|
||||
});
|
||||
// Clear value on click to allow re-selecting the same file
|
||||
fileInput?.addEventListener('click', () => {
|
||||
if (fileInput) fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) processBtn.addEventListener('click', deletePages);
|
||||
if (pagesInput) pagesInput.addEventListener('input', updatePreview);
|
||||
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) handleFile(droppedFiles[0]);
|
||||
});
|
||||
// Clear value on click to allow re-selecting the same file
|
||||
fileInput?.addEventListener('click', () => {
|
||||
if (fileInput) fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) processBtn.addEventListener('click', deletePages);
|
||||
if (pagesInput) pagesInput.addEventListener('input', updatePreview);
|
||||
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) handleFile(input.files[0]);
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) handleFile(input.files[0]);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
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;
|
||||
}
|
||||
|
||||
showLoader('Loading PDF...');
|
||||
deleteState.file = file;
|
||||
showLoader('Loading PDF...');
|
||||
deleteState.file = file;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
deleteState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
deleteState.pdfJsDoc = await getPDFDocument({ data: (arrayBuffer as ArrayBuffer).slice(0) }).promise;
|
||||
deleteState.totalPages = deleteState.pdfDoc.getPageCount();
|
||||
deleteState.pagesToDelete = new Set();
|
||||
try {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
deleteState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
deleteState.pdfJsDoc = await getPDFDocument({
|
||||
data: (arrayBuffer as ArrayBuffer).slice(0),
|
||||
}).promise;
|
||||
deleteState.totalPages = deleteState.pdfDoc.getPageCount();
|
||||
deleteState.pagesToDelete = new Set();
|
||||
|
||||
updateFileDisplay();
|
||||
showOptions();
|
||||
await renderThumbnails();
|
||||
hideLoader();
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
}
|
||||
updateFileDisplay();
|
||||
showOptions();
|
||||
await renderThumbnails();
|
||||
hideLoader();
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileDisplay() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !deleteState.file) return;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !deleteState.file) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const 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 = deleteState.file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = deleteState.file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(deleteState.file.size)} • ${deleteState.totalPages} pages`;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(deleteState.file.size)} • ${deleteState.totalPages} pages`;
|
||||
|
||||
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 showOptions() {
|
||||
const deleteOptions = document.getElementById('delete-options');
|
||||
const totalPagesSpan = document.getElementById('total-pages');
|
||||
const deleteOptions = document.getElementById('delete-options');
|
||||
const totalPagesSpan = document.getElementById('total-pages');
|
||||
|
||||
if (deleteOptions) deleteOptions.classList.remove('hidden');
|
||||
if (totalPagesSpan) totalPagesSpan.textContent = deleteState.totalPages.toString();
|
||||
if (deleteOptions) deleteOptions.classList.remove('hidden');
|
||||
if (totalPagesSpan)
|
||||
totalPagesSpan.textContent = deleteState.totalPages.toString();
|
||||
}
|
||||
|
||||
async function renderThumbnails() {
|
||||
const container = document.getElementById('delete-pages-preview');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
const container = document.getElementById('delete-pages-preview');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
for (let i = 1; i <= deleteState.totalPages; i++) {
|
||||
const page = await deleteState.pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.3 });
|
||||
for (let i = 1; i <= deleteState.totalPages; i++) {
|
||||
const page = await deleteState.pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.3 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'relative cursor-pointer group';
|
||||
wrapper.dataset.page = i.toString();
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'relative cursor-pointer group';
|
||||
wrapper.dataset.page = i.toString();
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'w-full h-28 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className =
|
||||
'w-full h-28 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'max-w-full max-h-full object-contain';
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'max-w-full max-h-full object-contain';
|
||||
|
||||
const pageLabel = document.createElement('span');
|
||||
pageLabel.className = 'absolute top-1 left-1 bg-gray-800 text-white text-xs px-1.5 py-0.5 rounded';
|
||||
pageLabel.textContent = `${i}`;
|
||||
const pageLabel = document.createElement('span');
|
||||
pageLabel.className =
|
||||
'absolute top-1 left-1 bg-gray-800 text-white text-xs px-1.5 py-0.5 rounded';
|
||||
pageLabel.textContent = `${i}`;
|
||||
|
||||
const deleteOverlay = document.createElement('div');
|
||||
deleteOverlay.className = 'absolute inset-0 bg-red-500/50 hidden items-center justify-center rounded-lg';
|
||||
deleteOverlay.innerHTML = '<i data-lucide="x" class="w-8 h-8 text-white"></i>';
|
||||
const deleteOverlay = document.createElement('div');
|
||||
deleteOverlay.className =
|
||||
'absolute inset-0 bg-red-500/50 hidden items-center justify-center rounded-lg';
|
||||
deleteOverlay.innerHTML =
|
||||
'<i data-lucide="x" class="w-8 h-8 text-white"></i>';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
wrapper.append(imgContainer, pageLabel, deleteOverlay);
|
||||
container.appendChild(wrapper);
|
||||
imgContainer.appendChild(img);
|
||||
wrapper.append(imgContainer, pageLabel, deleteOverlay);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
wrapper.addEventListener('click', () => togglePageDelete(i, wrapper));
|
||||
}
|
||||
createIcons({ icons });
|
||||
wrapper.addEventListener('click', () => togglePageDelete(i, wrapper));
|
||||
}
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function togglePageDelete(pageNum: number, wrapper: HTMLElement) {
|
||||
const overlay = wrapper.querySelector('.bg-red-500\\/50');
|
||||
if (deleteState.pagesToDelete.has(pageNum)) {
|
||||
deleteState.pagesToDelete.delete(pageNum);
|
||||
overlay?.classList.add('hidden');
|
||||
overlay?.classList.remove('flex');
|
||||
} else {
|
||||
deleteState.pagesToDelete.add(pageNum);
|
||||
overlay?.classList.remove('hidden');
|
||||
overlay?.classList.add('flex');
|
||||
}
|
||||
updateInputFromSelection();
|
||||
const overlay = wrapper.querySelector('.bg-red-500\\/50');
|
||||
if (deleteState.pagesToDelete.has(pageNum)) {
|
||||
deleteState.pagesToDelete.delete(pageNum);
|
||||
overlay?.classList.add('hidden');
|
||||
overlay?.classList.remove('flex');
|
||||
} else {
|
||||
deleteState.pagesToDelete.add(pageNum);
|
||||
overlay?.classList.remove('hidden');
|
||||
overlay?.classList.add('flex');
|
||||
}
|
||||
updateInputFromSelection();
|
||||
}
|
||||
|
||||
function updateInputFromSelection() {
|
||||
const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
if (pagesInput) {
|
||||
const sorted = Array.from(deleteState.pagesToDelete).sort((a, b) => a - b);
|
||||
pagesInput.value = sorted.join(', ');
|
||||
}
|
||||
const pagesInput = document.getElementById(
|
||||
'pages-to-delete'
|
||||
) as HTMLInputElement;
|
||||
if (pagesInput) {
|
||||
const sorted = Array.from(deleteState.pagesToDelete).sort((a, b) => a - b);
|
||||
pagesInput.value = sorted.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
if (!pagesInput) return;
|
||||
const pagesInput = document.getElementById(
|
||||
'pages-to-delete'
|
||||
) as HTMLInputElement;
|
||||
if (!pagesInput) return;
|
||||
|
||||
deleteState.pagesToDelete = new Set(parsePageRanges(pagesInput.value, deleteState.totalPages).map(i => i + 1));
|
||||
deleteState.pagesToDelete = new Set(
|
||||
parsePageRanges(pagesInput.value, deleteState.totalPages).map((i) => i + 1)
|
||||
);
|
||||
|
||||
const container = document.getElementById('delete-pages-preview');
|
||||
if (!container) return;
|
||||
const container = document.getElementById('delete-pages-preview');
|
||||
if (!container) return;
|
||||
|
||||
container.querySelectorAll('[data-page]').forEach((wrapper) => {
|
||||
const pageNum = parseInt((wrapper as HTMLElement).dataset.page || '0', 10);
|
||||
const overlay = wrapper.querySelector('.bg-red-500\\/50');
|
||||
if (deleteState.pagesToDelete.has(pageNum)) {
|
||||
overlay?.classList.remove('hidden');
|
||||
overlay?.classList.add('flex');
|
||||
} else {
|
||||
overlay?.classList.add('hidden');
|
||||
overlay?.classList.remove('flex');
|
||||
}
|
||||
});
|
||||
container.querySelectorAll('[data-page]').forEach((wrapper) => {
|
||||
const pageNum = parseInt((wrapper as HTMLElement).dataset.page || '0', 10);
|
||||
const overlay = wrapper.querySelector('.bg-red-500\\/50');
|
||||
if (deleteState.pagesToDelete.has(pageNum)) {
|
||||
overlay?.classList.remove('hidden');
|
||||
overlay?.classList.add('flex');
|
||||
} else {
|
||||
overlay?.classList.add('hidden');
|
||||
overlay?.classList.remove('flex');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function deletePages() {
|
||||
if (deleteState.pagesToDelete.size === 0) {
|
||||
showAlert('No Pages', 'Please select pages to delete.');
|
||||
return;
|
||||
}
|
||||
if (deleteState.pagesToDelete.size === 0) {
|
||||
showAlert('No Pages', 'Please select pages to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteState.pagesToDelete.size >= deleteState.totalPages) {
|
||||
showAlert('Error', 'Cannot delete all pages.');
|
||||
return;
|
||||
}
|
||||
if (deleteState.pagesToDelete.size >= deleteState.totalPages) {
|
||||
showAlert('Error', 'Cannot delete all pages.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Deleting pages...');
|
||||
showLoader('Deleting pages...');
|
||||
|
||||
try {
|
||||
const pagesToKeep = [];
|
||||
for (let i = 0; i < deleteState.totalPages; i++) {
|
||||
if (!deleteState.pagesToDelete.has(i + 1)) pagesToKeep.push(i);
|
||||
}
|
||||
try {
|
||||
const srcBytes = await deleteState.pdfDoc.save();
|
||||
const resultBytes = await deletePdfPages(
|
||||
new Uint8Array(srcBytes),
|
||||
deleteState.pagesToDelete
|
||||
);
|
||||
const baseName = deleteState.file?.name.replace('.pdf', '') || 'document';
|
||||
downloadFile(
|
||||
new Blob([resultBytes as unknown as BlobPart], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
`${baseName}_pages_removed.pdf`
|
||||
);
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(deleteState.pdfDoc, pagesToKeep);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const baseName = deleteState.file?.name.replace('.pdf', '') || 'document';
|
||||
downloadFile(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), `${baseName}_pages_removed.pdf`);
|
||||
|
||||
hideLoader();
|
||||
showAlert('Success', `Deleted ${deleteState.pagesToDelete.size} page(s) successfully!`, 'success', () => resetState());
|
||||
} catch (error) {
|
||||
console.error('Error deleting pages:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to delete pages.');
|
||||
}
|
||||
hideLoader();
|
||||
showAlert(
|
||||
'Success',
|
||||
`Deleted ${deleteState.pagesToDelete.size} page(s) successfully!`,
|
||||
'success',
|
||||
() => resetState()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting pages:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to delete pages.');
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
deleteState.file = null;
|
||||
deleteState.pdfDoc = null;
|
||||
deleteState.pdfJsDoc = null;
|
||||
deleteState.totalPages = 0;
|
||||
deleteState.pagesToDelete = new Set();
|
||||
deleteState.file = null;
|
||||
deleteState.pdfDoc = null;
|
||||
deleteState.pdfJsDoc = null;
|
||||
deleteState.totalPages = 0;
|
||||
deleteState.pagesToDelete = new Set();
|
||||
|
||||
document.getElementById('delete-options')?.classList.add('hidden');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const pagesInput = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
if (pagesInput) pagesInput.value = '';
|
||||
const container = document.getElementById('delete-pages-preview');
|
||||
if (container) container.innerHTML = '';
|
||||
document.getElementById('delete-options')?.classList.add('hidden');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const pagesInput = document.getElementById(
|
||||
'pages-to-delete'
|
||||
) as HTMLInputElement;
|
||||
if (pagesInput) pagesInput.value = '';
|
||||
const container = document.getElementById('delete-pages-preview');
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
+146
-103
@@ -2,130 +2,173 @@ import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import { applyInvertColors } from '../utils/image-effects.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { InvertColorsState } from '@/types';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const pageState: InvertColorsState = { file: null, pdfDoc: null };
|
||||
|
||||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); }
|
||||
else { initializePage(); }
|
||||
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
fileInput.addEventListener('click', () => { fileInput.value = ''; });
|
||||
}
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('border-indigo-500'); });
|
||||
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('border-indigo-500'); });
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); dropZone.classList.remove('border-indigo-500');
|
||||
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
if (backBtn) backBtn.addEventListener('click', () => { window.location.href = import.meta.env.BASE_URL; });
|
||||
if (processBtn) processBtn.addEventListener('click', invertColors);
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function handleFileUpload(e: Event) { const input = e.target as HTMLInputElement; if (input.files?.length) handleFiles(input.files); }
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-indigo-500');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
if (backBtn)
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
if (processBtn) processBtn.addEventListener('click', invertColors);
|
||||
}
|
||||
|
||||
function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) handleFiles(input.files);
|
||||
}
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
const file = files[0];
|
||||
if (!file || file.type !== 'application/pdf') { showAlert('Invalid File', 'Please upload a valid PDF file.'); return; }
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
pageState.file = file;
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
} catch (error) { console.error(error); showAlert('Error', 'Failed to load PDF file.'); }
|
||||
finally { hideLoader(); }
|
||||
const file = files[0];
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Invalid File', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
pageState.file = file;
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileDisplay() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return;
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = resetState;
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return;
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = resetState;
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
pageState.file = null; pageState.pdfDoc = null;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
async function invertColors() {
|
||||
if (!pageState.pdfDoc || !pageState.file) { showAlert('Error', 'Please upload a PDF file first.'); return; }
|
||||
showLoader('Inverting PDF colors...');
|
||||
try {
|
||||
const newPdfDoc = await PDFLibDocument.create();
|
||||
const pdfBytes = await pageState.pdfDoc.save();
|
||||
const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
if (!pageState.pdfDoc || !pageState.file) {
|
||||
showAlert('Error', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
showLoader('Inverting PDF colors...');
|
||||
try {
|
||||
const newPdfDoc = await PDFLibDocument.create();
|
||||
const pdfBytes = await pageState.pdfDoc.save();
|
||||
const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
showLoader(`Processing page ${i} of ${pdfjsDoc.numPages}...`);
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
showLoader(`Processing page ${i} of ${pdfjsDoc.numPages}...`);
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
for (let j = 0; j < data.length; j += 4) {
|
||||
data[j] = 255 - data[j];
|
||||
data[j + 1] = 255 - data[j + 1];
|
||||
data[j + 2] = 255 - data[j + 2];
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
applyInvertColors(imageData);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const pngImageBytes = await new Promise<Uint8Array>((resolve) =>
|
||||
canvas.toBlob((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
reader.readAsArrayBuffer(blob!);
|
||||
}, 'image/png')
|
||||
);
|
||||
const pngImageBytes = await new Promise<Uint8Array>((resolve) =>
|
||||
canvas.toBlob((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
reader.readAsArrayBuffer(blob!);
|
||||
}, 'image/png')
|
||||
);
|
||||
|
||||
const image = await newPdfDoc.embedPng(pngImageBytes);
|
||||
const newPage = newPdfDoc.addPage([image.width, image.height]);
|
||||
newPage.drawImage(image, { x: 0, y: 0, width: image.width, height: image.height });
|
||||
}
|
||||
const newPdfBytes = await newPdfDoc.save();
|
||||
downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'inverted.pdf');
|
||||
showAlert('Success', 'Colors inverted successfully!', 'success', () => { resetState(); });
|
||||
} catch (e) { console.error(e); showAlert('Error', 'Could not invert PDF colors.'); }
|
||||
finally { hideLoader(); }
|
||||
const image = await newPdfDoc.embedPng(pngImageBytes);
|
||||
const newPage = newPdfDoc.addPage([image.width, image.height]);
|
||||
newPage.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
const newPdfBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'inverted.pdf'
|
||||
);
|
||||
showAlert('Success', 'Colors inverted successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not invert PDF colors.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
+11
-276
@@ -1,28 +1,9 @@
|
||||
import { tesseractLanguages } from '../config/tesseract-languages.js';
|
||||
import { showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
|
||||
import Tesseract from 'tesseract.js';
|
||||
import {
|
||||
PDFDocument as PDFLibDocument,
|
||||
StandardFonts,
|
||||
rgb,
|
||||
PDFFont,
|
||||
} from 'pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { downloadFile, formatBytes } from '../utils/helpers.js';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { getFontForLanguage } from '../utils/font-loader.js';
|
||||
import { OcrState, OcrLine, OcrPage } from '@/types';
|
||||
import {
|
||||
parseHocrDocument,
|
||||
calculateWordTransform,
|
||||
calculateSpaceTransform,
|
||||
} from '../utils/hocr-transform.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
import { OcrState } from '@/types';
|
||||
import { performOcr } from '../utils/ocr.js';
|
||||
|
||||
const pageState: OcrState = {
|
||||
file: null,
|
||||
@@ -40,108 +21,6 @@ const whitelistPresets: Record<string, string> = {
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:',
|
||||
};
|
||||
|
||||
function drawOcrTextLayer(
|
||||
page: ReturnType<typeof PDFLibDocument.prototype.addPage>,
|
||||
ocrPage: OcrPage,
|
||||
pageHeight: number,
|
||||
primaryFont: PDFFont,
|
||||
latinFont: PDFFont
|
||||
): void {
|
||||
ocrPage.lines.forEach(function (line: OcrLine) {
|
||||
const words = line.words;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
const text = word.text.replace(
|
||||
/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g,
|
||||
''
|
||||
);
|
||||
|
||||
if (!text.trim()) continue;
|
||||
|
||||
const hasNonLatin = /[^\u0000-\u007F]/.test(text);
|
||||
const font = hasNonLatin ? primaryFont : latinFont;
|
||||
|
||||
if (!font) {
|
||||
console.warn('Font not available for text: "' + text + '"');
|
||||
continue;
|
||||
}
|
||||
|
||||
const transform = calculateWordTransform(
|
||||
word,
|
||||
line,
|
||||
pageHeight,
|
||||
(txt: string, size: number) => {
|
||||
try {
|
||||
return font.widthOfTextAtSize(txt, size);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (transform.fontSize <= 0) continue;
|
||||
|
||||
try {
|
||||
page.drawText(text, {
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
font,
|
||||
size: transform.fontSize,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Could not draw text "${text}":`, error);
|
||||
}
|
||||
|
||||
if (line.injectWordBreaks && i < words.length - 1) {
|
||||
const nextWord = words[i + 1];
|
||||
const spaceTransform = calculateSpaceTransform(
|
||||
word,
|
||||
nextWord,
|
||||
line,
|
||||
pageHeight,
|
||||
(size: number) => {
|
||||
try {
|
||||
return font.widthOfTextAtSize(' ', size);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (spaceTransform && spaceTransform.horizontalScale > 0.1) {
|
||||
try {
|
||||
page.drawText(' ', {
|
||||
x: spaceTransform.x,
|
||||
y: spaceTransform.y,
|
||||
font,
|
||||
size: spaceTransform.fontSize,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
});
|
||||
} catch {
|
||||
console.warn(`Could not draw space between words`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function binarizeCanvas(ctx: CanvasRenderingContext2D) {
|
||||
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const brightness =
|
||||
0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||
const color = brightness > 128 ? 255 : 0;
|
||||
data[i] = data[i + 1] = data[i + 2] = color;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function updateProgress(status: string, progress: number) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressStatus = document.getElementById('progress-status');
|
||||
@@ -239,161 +118,17 @@ async function runOCR() {
|
||||
if (ocrProgress) ocrProgress.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const worker = await Tesseract.createWorker(langString, 1, {
|
||||
logger: function (m: { status: string; progress: number }) {
|
||||
updateProgress(m.status, m.progress || 0);
|
||||
},
|
||||
});
|
||||
|
||||
await worker.setParameters({
|
||||
tessjs_create_hocr: '1',
|
||||
tessedit_pageseg_mode: Tesseract.PSM.AUTO,
|
||||
});
|
||||
|
||||
if (whitelist) {
|
||||
await worker.setParameters({
|
||||
tessedit_char_whitelist: whitelist,
|
||||
});
|
||||
}
|
||||
|
||||
const arrayBuffer = await pageState.file.arrayBuffer();
|
||||
const pdf = await getPDFDocument({ data: arrayBuffer }).promise;
|
||||
const newPdfDoc = await PDFLibDocument.create();
|
||||
|
||||
newPdfDoc.registerFontkit(fontkit);
|
||||
|
||||
updateProgress('Loading fonts...', 0);
|
||||
|
||||
const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
|
||||
const indicLangs = [
|
||||
'hin',
|
||||
'ben',
|
||||
'guj',
|
||||
'kan',
|
||||
'mal',
|
||||
'ori',
|
||||
'pan',
|
||||
'tam',
|
||||
'tel',
|
||||
'sin',
|
||||
];
|
||||
const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
|
||||
|
||||
const primaryLang =
|
||||
selectedLangs.find(function (l) {
|
||||
return priorityLangs.includes(l);
|
||||
}) ||
|
||||
selectedLangs[0] ||
|
||||
'eng';
|
||||
|
||||
const hasCJK = selectedLangs.some(function (l) {
|
||||
return cjkLangs.includes(l);
|
||||
const result = await performOcr(new Uint8Array(arrayBuffer), {
|
||||
language: langString,
|
||||
resolution: scale,
|
||||
binarize,
|
||||
whitelist,
|
||||
onProgress: updateProgress,
|
||||
});
|
||||
const hasIndic = selectedLangs.some(function (l) {
|
||||
return indicLangs.includes(l);
|
||||
});
|
||||
const hasLatin =
|
||||
selectedLangs.some(function (l) {
|
||||
return !priorityLangs.includes(l);
|
||||
}) || selectedLangs.includes('eng');
|
||||
const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
|
||||
|
||||
let primaryFont;
|
||||
let latinFont;
|
||||
|
||||
try {
|
||||
if (isIndicPlusLatin) {
|
||||
const [scriptFontBytes, latinFontBytes] = await Promise.all([
|
||||
getFontForLanguage(primaryLang),
|
||||
getFontForLanguage('eng'),
|
||||
]);
|
||||
primaryFont = await newPdfDoc.embedFont(scriptFontBytes, {
|
||||
subset: false,
|
||||
});
|
||||
latinFont = await newPdfDoc.embedFont(latinFontBytes, {
|
||||
subset: false,
|
||||
});
|
||||
} else {
|
||||
const fontBytes = await getFontForLanguage(primaryLang);
|
||||
primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false });
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Font loading failed, falling back to Helvetica', e);
|
||||
primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
latinFont = primaryFont;
|
||||
showAlert(
|
||||
'Font Warning',
|
||||
'Could not load the specific font for this language. Some characters may not appear correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
let fullText = '';
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
updateProgress(
|
||||
`Processing page ${i} of ${pdf.numPages}`,
|
||||
(i - 1) / pdf.numPages
|
||||
);
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext('2d')!;
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
|
||||
if (binarize) {
|
||||
binarizeCanvas(context);
|
||||
}
|
||||
|
||||
const result = await worker.recognize(
|
||||
canvas,
|
||||
{},
|
||||
{ text: true, hocr: true }
|
||||
);
|
||||
const data = result.data;
|
||||
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
|
||||
const pngImageBytes = await new Promise<Uint8Array>(function (resolve) {
|
||||
canvas.toBlob(function (blob) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
reader.readAsArrayBuffer(blob!);
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
const pngImage = await newPdfDoc.embedPng(pngImageBytes);
|
||||
newPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
if (data.hocr) {
|
||||
const ocrPage = parseHocrDocument(data.hocr);
|
||||
drawOcrTextLayer(
|
||||
newPage,
|
||||
ocrPage,
|
||||
viewport.height,
|
||||
primaryFont,
|
||||
latinFont
|
||||
);
|
||||
}
|
||||
|
||||
fullText += data.text + '\n\n';
|
||||
}
|
||||
|
||||
await worker.terminate();
|
||||
|
||||
pageState.searchablePdfBytes = await newPdfDoc.save();
|
||||
pageState.searchablePdfBytes = result.pdfBytes;
|
||||
|
||||
const ocrResults = document.getElementById('ocr-results');
|
||||
if (ocrProgress) ocrProgress.classList.add('hidden');
|
||||
@@ -404,7 +139,7 @@ async function runOCR() {
|
||||
const textOutput = document.getElementById(
|
||||
'ocr-text-output'
|
||||
) as HTMLTextAreaElement;
|
||||
if (textOutput) textOutput.value = fullText.trim();
|
||||
if (textOutput) textOutput.value = result.fullText.trim();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
|
||||
+140
-180
@@ -1,230 +1,190 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js';
|
||||
import { PDFDocument as PDFLibDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import {
|
||||
addPageNumbers as addPageNumbersToPdf,
|
||||
type PageNumberPosition,
|
||||
type PageNumberFormat,
|
||||
} from '../utils/pdf-operations.js';
|
||||
|
||||
interface PageState {
|
||||
file: File | null;
|
||||
pdfDoc: PDFLibDocument | null;
|
||||
file: File | null;
|
||||
pdfDoc: PDFLibDocument | null;
|
||||
}
|
||||
|
||||
const pageState: PageState = {
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
fileInput.addEventListener('click', () => { fileInput.value = ''; });
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handleFileUpload);
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-indigo-500');
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-indigo-500');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
if (e.dataTransfer?.files.length) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
}
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
if (e.dataTransfer?.files.length) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', addPageNumbers);
|
||||
}
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', addPageNumbers);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
handleFiles(input.files);
|
||||
}
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
handleFiles(input.files);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
const file = files[0];
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Invalid File', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Invalid File', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
pageState.file = file;
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
pageState.file = file;
|
||||
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileDisplay() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const 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 = pageState.file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageState.pdfDoc.getPageCount()} pages`;
|
||||
|
||||
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 resetState() {
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
async function addPageNumbers() {
|
||||
if (!pageState.pdfDoc) {
|
||||
showAlert('Error', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
if (!pageState.pdfDoc) {
|
||||
showAlert('Error', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Adding page numbers...');
|
||||
try {
|
||||
const position = (document.getElementById('position') as HTMLSelectElement).value;
|
||||
const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement).value) || 12;
|
||||
const format = (document.getElementById('number-format') as HTMLSelectElement).value;
|
||||
const colorHex = (document.getElementById('text-color') as HTMLInputElement).value;
|
||||
const textColor = hexToRgb(colorHex);
|
||||
showLoader('Adding page numbers...');
|
||||
try {
|
||||
const position = (document.getElementById('position') as HTMLSelectElement)
|
||||
.value as PageNumberPosition;
|
||||
const fontSize =
|
||||
parseInt(
|
||||
(document.getElementById('font-size') as HTMLInputElement).value
|
||||
) || 12;
|
||||
const format =
|
||||
(document.getElementById('number-format') as HTMLSelectElement).value ===
|
||||
'page_x_of_y'
|
||||
? ('page_x_of_y' as PageNumberFormat)
|
||||
: ('simple' as PageNumberFormat);
|
||||
const colorHex = (document.getElementById('text-color') as HTMLInputElement)
|
||||
.value;
|
||||
const textColor = hexToRgb(colorHex);
|
||||
|
||||
const pages = pageState.pdfDoc.getPages();
|
||||
const totalPages = pages.length;
|
||||
const helveticaFont = await pageState.pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const pdfBytes = new Uint8Array(await pageState.pdfDoc.save());
|
||||
const resultBytes = await addPageNumbersToPdf(pdfBytes, {
|
||||
position,
|
||||
fontSize,
|
||||
format,
|
||||
color: textColor,
|
||||
});
|
||||
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
const page = pages[i];
|
||||
|
||||
const mediaBox = page.getMediaBox();
|
||||
const cropBox = page.getCropBox();
|
||||
const bounds = cropBox || mediaBox;
|
||||
const width = bounds.width;
|
||||
const height = bounds.height;
|
||||
const xOffset = bounds.x || 0;
|
||||
const yOffset = bounds.y || 0;
|
||||
|
||||
const pageNumText = format === 'page_x_of_y' ? `${i + 1} / ${totalPages}` : `${i + 1}`;
|
||||
|
||||
const textWidth = helveticaFont.widthOfTextAtSize(pageNumText, fontSize);
|
||||
const textHeight = fontSize;
|
||||
|
||||
const minMargin = 8;
|
||||
const maxMargin = 40;
|
||||
const marginPercentage = 0.04;
|
||||
|
||||
const horizontalMargin = Math.max(minMargin, Math.min(maxMargin, width * marginPercentage));
|
||||
const verticalMargin = Math.max(minMargin, Math.min(maxMargin, height * marginPercentage));
|
||||
|
||||
const safeHorizontalMargin = Math.max(horizontalMargin, textWidth / 2 + 3);
|
||||
const safeVerticalMargin = Math.max(verticalMargin, textHeight + 3);
|
||||
|
||||
let x = 0, y = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'bottom-center':
|
||||
x = Math.max(safeHorizontalMargin, Math.min(width - safeHorizontalMargin - textWidth, (width - textWidth) / 2)) + xOffset;
|
||||
y = safeVerticalMargin + yOffset;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = safeHorizontalMargin + xOffset;
|
||||
y = safeVerticalMargin + yOffset;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = Math.max(safeHorizontalMargin, width - safeHorizontalMargin - textWidth) + xOffset;
|
||||
y = safeVerticalMargin + yOffset;
|
||||
break;
|
||||
case 'top-center':
|
||||
x = Math.max(safeHorizontalMargin, Math.min(width - safeHorizontalMargin - textWidth, (width - textWidth) / 2)) + xOffset;
|
||||
y = height - safeVerticalMargin - textHeight + yOffset;
|
||||
break;
|
||||
case 'top-left':
|
||||
x = safeHorizontalMargin + xOffset;
|
||||
y = height - safeVerticalMargin - textHeight + yOffset;
|
||||
break;
|
||||
case 'top-right':
|
||||
x = Math.max(safeHorizontalMargin, width - safeHorizontalMargin - textWidth) + xOffset;
|
||||
y = height - safeVerticalMargin - textHeight + yOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
x = Math.max(xOffset + 3, Math.min(xOffset + width - textWidth - 3, x));
|
||||
y = Math.max(yOffset + 3, Math.min(yOffset + height - textHeight - 3, y));
|
||||
|
||||
page.drawText(pageNumText, {
|
||||
x,
|
||||
y,
|
||||
font: helveticaFont,
|
||||
size: fontSize,
|
||||
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||
});
|
||||
}
|
||||
|
||||
const newPdfBytes = await pageState.pdfDoc.save();
|
||||
downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'paginated.pdf');
|
||||
showAlert('Success', 'Page numbers added successfully!', 'success', () => { resetState(); });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not add page numbers.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
downloadFile(
|
||||
new Blob([resultBytes as unknown as BlobPart], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
'paginated.pdf'
|
||||
);
|
||||
showAlert('Success', 'Page numbers added successfully!', 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not add page numbers.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
@@ -1,206 +1,222 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
|
||||
import {
|
||||
downloadFile,
|
||||
formatBytes,
|
||||
readFileAsArrayBuffer,
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { applyGreyscale } from '../utils/image-effects.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
let files: File[] = [];
|
||||
|
||||
const updateUI = () => {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const optionsPanel = document.getElementById('options-panel');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const optionsPanel = document.getElementById('options-panel');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
|
||||
if (!fileDisplayArea || !optionsPanel || !dropZone) return;
|
||||
if (!fileDisplayArea || !optionsPanel || !dropZone) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
if (files.length > 0) {
|
||||
optionsPanel.classList.remove('hidden');
|
||||
if (files.length > 0) {
|
||||
optionsPanel.classList.remove('hidden');
|
||||
|
||||
// Render files synchronously first
|
||||
files.forEach((file) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
// Render files synchronously first
|
||||
files.forEach((file) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • Loading pages...`; // Initial state
|
||||
|
||||
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 = () => {
|
||||
files = [];
|
||||
updateUI();
|
||||
};
|
||||
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 = [];
|
||||
updateUI();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
|
||||
// Fetch page count asynchronously
|
||||
readFileAsArrayBuffer(file).then(buffer => {
|
||||
return getPDFDocument(buffer).promise;
|
||||
}).then(pdf => {
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`;
|
||||
}).catch(e => {
|
||||
console.warn('Error loading PDF page count:', e);
|
||||
metaSpan.textContent = formatBytes(file.size);
|
||||
});
|
||||
// Fetch page count asynchronously
|
||||
readFileAsArrayBuffer(file)
|
||||
.then((buffer) => {
|
||||
return getPDFDocument(buffer).promise;
|
||||
})
|
||||
.then((pdf) => {
|
||||
metaSpan.textContent = `${formatBytes(file.size)} • ${pdf.numPages} page${pdf.numPages !== 1 ? 's' : ''}`;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Error loading PDF page count:', e);
|
||||
metaSpan.textContent = formatBytes(file.size);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize icons immediately after synchronous render
|
||||
createIcons({ icons });
|
||||
} else {
|
||||
optionsPanel.classList.add('hidden');
|
||||
}
|
||||
// Initialize icons immediately after synchronous render
|
||||
createIcons({ icons });
|
||||
} else {
|
||||
optionsPanel.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
files = [];
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
updateUI();
|
||||
files = [];
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
updateUI();
|
||||
};
|
||||
|
||||
async function convert() {
|
||||
if (files.length === 0) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
showLoader('Converting to greyscale...');
|
||||
try {
|
||||
const pdfBytes = await readFileAsArrayBuffer(files[0]) as ArrayBuffer;
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
if (files.length === 0) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
showLoader('Converting to greyscale...');
|
||||
try {
|
||||
const pdfBytes = (await readFileAsArrayBuffer(files[0])) as ArrayBuffer;
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
await page.render({ canvasContext: context!, viewport: viewport, canvas }).promise;
|
||||
await page.render({ canvasContext: context!, viewport: viewport, canvas })
|
||||
.promise;
|
||||
|
||||
const imageData = context!.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const imageData = context!.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
applyGreyscale(imageData);
|
||||
context!.putImageData(imageData, 0, 0);
|
||||
|
||||
// Convert to greyscale
|
||||
for (let j = 0; j < data.length; j += 4) {
|
||||
const grey = Math.round(0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2]);
|
||||
data[j] = grey;
|
||||
data[j + 1] = grey;
|
||||
data[j + 2] = grey;
|
||||
}
|
||||
const jpegBlob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', 0.9)
|
||||
);
|
||||
|
||||
context!.putImageData(imageData, 0, 0);
|
||||
|
||||
const jpegBlob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', 0.9)
|
||||
);
|
||||
|
||||
if (jpegBlob) {
|
||||
const jpegBytes = await jpegBlob.arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
|
||||
'greyscale.pdf'
|
||||
);
|
||||
showAlert('Success', 'PDF converted to greyscale successfully!', 'success', () => {
|
||||
resetState();
|
||||
if (jpegBlob) {
|
||||
const jpegBytes = await jpegBlob.arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to convert PDF to greyscale. The file might be corrupted.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = await newPdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
|
||||
'greyscale.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF converted to greyscale successfully!',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Failed to convert PDF to greyscale. The file might be corrupted.'
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
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 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 === 'application/pdf'
|
||||
);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
showAlert('Invalid File', 'Please upload a PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFileSelect = (newFiles: FileList | null) => {
|
||||
if (!newFiles || newFiles.length === 0) return;
|
||||
const validFiles = Array.from(newFiles).filter(
|
||||
(file) => file.type === 'application/pdf'
|
||||
);
|
||||
files = [validFiles[0]];
|
||||
updateUI();
|
||||
};
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
showAlert('Invalid File', 'Please upload a PDF file.');
|
||||
return;
|
||||
}
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
files = [validFiles[0]];
|
||||
updateUI();
|
||||
};
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
handleFileSelect(e.dataTransfer?.files ?? null);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
handleFileSelect(e.dataTransfer?.files ?? null);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', convert);
|
||||
}
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', convert);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+278
-276
@@ -1,355 +1,357 @@
|
||||
import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { PDFDocument as PDFLibDocument, degrees } from 'pdf-lib';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import {
|
||||
renderPagesProgressively,
|
||||
cleanupLazyRendering,
|
||||
} from '../utils/render-utils.js';
|
||||
import { rotatePdfPages } from '../utils/pdf-operations.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
interface RotateState {
|
||||
file: File | null;
|
||||
pdfDoc: PDFLibDocument | null;
|
||||
pdfJsDoc: pdfjsLib.PDFDocumentProxy | null;
|
||||
rotations: number[];
|
||||
file: File | null;
|
||||
pdfDoc: PDFLibDocument | null;
|
||||
pdfJsDoc: pdfjsLib.PDFDocumentProxy | null;
|
||||
rotations: number[];
|
||||
}
|
||||
|
||||
const pageState: RotateState = {
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
pdfJsDoc: null,
|
||||
rotations: [],
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
pdfJsDoc: null,
|
||||
rotations: [],
|
||||
};
|
||||
|
||||
function resetState() {
|
||||
cleanupLazyRendering();
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
pageState.pdfJsDoc = null;
|
||||
pageState.rotations = [];
|
||||
cleanupLazyRendering();
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
pageState.pdfJsDoc = null;
|
||||
pageState.rotations = [];
|
||||
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
|
||||
const pageThumbnails = document.getElementById('page-thumbnails');
|
||||
if (pageThumbnails) pageThumbnails.innerHTML = '';
|
||||
const pageThumbnails = document.getElementById('page-thumbnails');
|
||||
if (pageThumbnails) pageThumbnails.innerHTML = '';
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
function updateAllRotationDisplays() {
|
||||
for (let i = 0; i < pageState.rotations.length; i++) {
|
||||
const container = document.querySelector(`[data-page-index="${i}"]`);
|
||||
if (container) {
|
||||
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
|
||||
if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[i]}deg)`;
|
||||
}
|
||||
for (let i = 0; i < pageState.rotations.length; i++) {
|
||||
const container = document.querySelector(`[data-page-index="${i}"]`);
|
||||
if (container) {
|
||||
const wrapper = container.querySelector(
|
||||
'.thumbnail-wrapper'
|
||||
) as HTMLElement;
|
||||
if (wrapper)
|
||||
wrapper.style.transform = `rotate(${pageState.rotations[i]}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPageWrapper(canvas: HTMLCanvasElement, pageNumber: number): HTMLElement {
|
||||
const pageIndex = pageNumber - 1;
|
||||
function createPageWrapper(
|
||||
canvas: HTMLCanvasElement,
|
||||
pageNumber: number
|
||||
): HTMLElement {
|
||||
const pageIndex = pageNumber - 1;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'page-thumbnail relative bg-gray-700 rounded-lg overflow-hidden';
|
||||
container.dataset.pageIndex = pageIndex.toString();
|
||||
container.dataset.pageNumber = pageNumber.toString();
|
||||
const container = document.createElement('div');
|
||||
container.className =
|
||||
'page-thumbnail relative bg-gray-700 rounded-lg overflow-hidden';
|
||||
container.dataset.pageIndex = pageIndex.toString();
|
||||
container.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const canvasWrapper = document.createElement('div');
|
||||
canvasWrapper.className = 'thumbnail-wrapper flex items-center justify-center p-2 h-36';
|
||||
canvasWrapper.style.transition = 'transform 0.3s ease';
|
||||
// Apply initial rotation if it exists
|
||||
const initialRotation = pageState.rotations[pageIndex] || 0;
|
||||
canvasWrapper.style.transform = `rotate(${initialRotation}deg)`;
|
||||
const canvasWrapper = document.createElement('div');
|
||||
canvasWrapper.className =
|
||||
'thumbnail-wrapper flex items-center justify-center p-2 h-36';
|
||||
canvasWrapper.style.transition = 'transform 0.3s ease';
|
||||
// Apply initial rotation if it exists
|
||||
const initialRotation = pageState.rotations[pageIndex] || 0;
|
||||
canvasWrapper.style.transform = `rotate(${initialRotation}deg)`;
|
||||
|
||||
canvas.className = 'max-w-full max-h-full object-contain';
|
||||
canvasWrapper.appendChild(canvas);
|
||||
canvas.className = 'max-w-full max-h-full object-contain';
|
||||
canvasWrapper.appendChild(canvas);
|
||||
|
||||
const pageLabel = document.createElement('div');
|
||||
pageLabel.className = 'absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded';
|
||||
pageLabel.textContent = `${pageNumber}`;
|
||||
const pageLabel = document.createElement('div');
|
||||
pageLabel.className =
|
||||
'absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded';
|
||||
pageLabel.textContent = `${pageNumber}`;
|
||||
|
||||
container.appendChild(canvasWrapper);
|
||||
container.appendChild(pageLabel);
|
||||
container.appendChild(canvasWrapper);
|
||||
container.appendChild(pageLabel);
|
||||
|
||||
// Per-page rotation controls - Left and Right buttons only
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'flex items-center justify-center gap-2 p-2 bg-gray-800';
|
||||
// Per-page rotation controls - Left and Right buttons only
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'flex items-center justify-center gap-2 p-2 bg-gray-800';
|
||||
|
||||
const rotateLeftBtn = document.createElement('button');
|
||||
rotateLeftBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs';
|
||||
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-3 h-3"></i>';
|
||||
rotateLeftBtn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90;
|
||||
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
|
||||
if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
|
||||
};
|
||||
const rotateLeftBtn = document.createElement('button');
|
||||
rotateLeftBtn.className =
|
||||
'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs';
|
||||
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-3 h-3"></i>';
|
||||
rotateLeftBtn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90;
|
||||
const wrapper = container.querySelector(
|
||||
'.thumbnail-wrapper'
|
||||
) as HTMLElement;
|
||||
if (wrapper)
|
||||
wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
|
||||
};
|
||||
|
||||
const rotateRightBtn = document.createElement('button');
|
||||
rotateRightBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs';
|
||||
rotateRightBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-3 h-3"></i>';
|
||||
rotateRightBtn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90;
|
||||
const wrapper = container.querySelector('.thumbnail-wrapper') as HTMLElement;
|
||||
if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
|
||||
};
|
||||
const rotateRightBtn = document.createElement('button');
|
||||
rotateRightBtn.className =
|
||||
'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs';
|
||||
rotateRightBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-3 h-3"></i>';
|
||||
rotateRightBtn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90;
|
||||
const wrapper = container.querySelector(
|
||||
'.thumbnail-wrapper'
|
||||
) as HTMLElement;
|
||||
if (wrapper)
|
||||
wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
|
||||
};
|
||||
|
||||
controls.append(rotateLeftBtn, rotateRightBtn);
|
||||
container.appendChild(controls);
|
||||
controls.append(rotateLeftBtn, rotateRightBtn);
|
||||
container.appendChild(controls);
|
||||
|
||||
// Re-create icons for the new element
|
||||
setTimeout(function () {
|
||||
createIcons({ icons });
|
||||
}, 0);
|
||||
// Re-create icons for the new element
|
||||
setTimeout(function () {
|
||||
createIcons({ icons });
|
||||
}, 0);
|
||||
|
||||
return container;
|
||||
return container;
|
||||
}
|
||||
|
||||
async function renderThumbnails() {
|
||||
const pageThumbnails = document.getElementById('page-thumbnails');
|
||||
if (!pageThumbnails || !pageState.pdfJsDoc) return;
|
||||
const pageThumbnails = document.getElementById('page-thumbnails');
|
||||
if (!pageThumbnails || !pageState.pdfJsDoc) return;
|
||||
|
||||
pageThumbnails.innerHTML = '';
|
||||
pageThumbnails.innerHTML = '';
|
||||
|
||||
await renderPagesProgressively(
|
||||
pageState.pdfJsDoc,
|
||||
pageThumbnails,
|
||||
createPageWrapper,
|
||||
{
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '200px',
|
||||
eagerLoadBatches: 2,
|
||||
onBatchComplete: function () {
|
||||
createIcons({ icons });
|
||||
}
|
||||
}
|
||||
);
|
||||
await renderPagesProgressively(
|
||||
pageState.pdfJsDoc,
|
||||
pageThumbnails,
|
||||
createPageWrapper,
|
||||
{
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '200px',
|
||||
eagerLoadBatches: 2,
|
||||
onBatchComplete: function () {
|
||||
createIcons({ icons });
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
async function updateUI() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
|
||||
if (!fileDisplayArea) return;
|
||||
if (!fileDisplayArea) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • Loading...`;
|
||||
|
||||
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 = function () {
|
||||
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 = function () {
|
||||
resetState();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
|
||||
try {
|
||||
showLoader('Loading PDF...');
|
||||
const arrayBuffer = await pageState.file.arrayBuffer();
|
||||
try {
|
||||
showLoader('Loading PDF...');
|
||||
const arrayBuffer = await pageState.file.arrayBuffer();
|
||||
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer.slice(0), {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false
|
||||
});
|
||||
pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer.slice(0), {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
|
||||
pageState.pdfJsDoc = await getPDFDocument({ data: arrayBuffer.slice(0) }).promise;
|
||||
pageState.pdfJsDoc = await getPDFDocument({ data: arrayBuffer.slice(0) })
|
||||
.promise;
|
||||
|
||||
const pageCount = pageState.pdfDoc.getPageCount();
|
||||
pageState.rotations = new Array(pageCount).fill(0);
|
||||
const pageCount = pageState.pdfDoc.getPageCount();
|
||||
pageState.rotations = new Array(pageCount).fill(0);
|
||||
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
|
||||
metaSpan.textContent = `${formatBytes(pageState.file.size)} • ${pageCount} pages`;
|
||||
|
||||
await renderThumbnails();
|
||||
hideLoader();
|
||||
await renderThumbnails();
|
||||
hideLoader();
|
||||
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
resetState();
|
||||
}
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
resetState();
|
||||
}
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRotations() {
|
||||
if (!pageState.pdfDoc || !pageState.file) {
|
||||
showAlert('Error', 'Please upload a PDF first.');
|
||||
return;
|
||||
}
|
||||
if (!pageState.pdfDoc || !pageState.file) {
|
||||
showAlert('Error', 'Please upload a PDF first.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Applying rotations...');
|
||||
showLoader('Applying rotations...');
|
||||
|
||||
try {
|
||||
const pageCount = pageState.pdfDoc.getPageCount();
|
||||
const newPdfDoc = await PDFLibDocument.create();
|
||||
try {
|
||||
const pdfBytes = await pageState.pdfDoc.save();
|
||||
const rotatedPdfBytes = await rotatePdfPages(
|
||||
new Uint8Array(pdfBytes),
|
||||
pageState.rotations
|
||||
);
|
||||
const originalName = pageState.file.name.replace(/\.pdf$/i, '');
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const rotation = pageState.rotations[i] || 0;
|
||||
const originalPage = pageState.pdfDoc.getPage(i);
|
||||
const currentRotation = originalPage.getRotation().angle;
|
||||
const totalRotation = currentRotation + rotation;
|
||||
downloadFile(
|
||||
new Blob([rotatedPdfBytes as unknown as BlobPart], {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
`${originalName}_rotated.pdf`
|
||||
);
|
||||
|
||||
console.log(`Page ${i}: rotation=${rotation}, currentRotation=${currentRotation}, totalRotation=${totalRotation}, applying=${-totalRotation}`);
|
||||
|
||||
if (totalRotation % 90 === 0) {
|
||||
const [copiedPage] = await newPdfDoc.copyPages(pageState.pdfDoc, [i]);
|
||||
copiedPage.setRotation(degrees(totalRotation));
|
||||
newPdfDoc.addPage(copiedPage);
|
||||
} else {
|
||||
const embeddedPage = await newPdfDoc.embedPage(originalPage);
|
||||
const { width, height } = embeddedPage.scale(1);
|
||||
|
||||
const angleRad = (totalRotation * Math.PI) / 180;
|
||||
const absCos = Math.abs(Math.cos(angleRad));
|
||||
const absSin = Math.abs(Math.sin(angleRad));
|
||||
|
||||
const newWidth = width * absCos + height * absSin;
|
||||
const newHeight = width * absSin + height * absCos;
|
||||
|
||||
const newPage = newPdfDoc.addPage([newWidth, newHeight]);
|
||||
|
||||
const x = newWidth / 2 - (width / 2 * Math.cos(angleRad) - height / 2 * Math.sin(angleRad));
|
||||
const y = newHeight / 2 - (width / 2 * Math.sin(angleRad) + height / 2 * Math.cos(angleRad));
|
||||
|
||||
newPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotate: degrees(totalRotation),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
);
|
||||
|
||||
showAlert('Success', 'Rotations applied successfully!', 'success', function () {
|
||||
resetState();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not apply rotations.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
showAlert(
|
||||
'Success',
|
||||
'Rotations applied successfully!',
|
||||
'success',
|
||||
function () {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not apply rotations.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(files: FileList | null) {
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pageState.file = file;
|
||||
updateUI();
|
||||
}
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
pageState.file = file;
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const rotateAllLeft = document.getElementById('rotate-all-left');
|
||||
const rotateAllRight = document.getElementById('rotate-all-right');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const rotateAllLeft = document.getElementById('rotate-all-left');
|
||||
const rotateAllRight = document.getElementById('rotate-all-right');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
if (rotateAllLeft) {
|
||||
rotateAllLeft.addEventListener('click', function () {
|
||||
for (let i = 0; i < pageState.rotations.length; i++) {
|
||||
pageState.rotations[i] = pageState.rotations[i] - 90;
|
||||
}
|
||||
updateAllRotationDisplays();
|
||||
});
|
||||
}
|
||||
|
||||
if (rotateAllRight) {
|
||||
rotateAllRight.addEventListener('click', function () {
|
||||
for (let i = 0; i < pageState.rotations.length; i++) {
|
||||
pageState.rotations[i] = pageState.rotations[i] + 90;
|
||||
}
|
||||
updateAllRotationDisplays();
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(function (f) {
|
||||
return (
|
||||
f.type === 'application/pdf' ||
|
||||
f.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
});
|
||||
}
|
||||
if (pdfFiles.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(pdfFiles[0]);
|
||||
handleFileSelect(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (rotateAllLeft) {
|
||||
rotateAllLeft.addEventListener('click', function () {
|
||||
for (let i = 0; i < pageState.rotations.length; i++) {
|
||||
pageState.rotations[i] = pageState.rotations[i] - 90;
|
||||
}
|
||||
updateAllRotationDisplays();
|
||||
});
|
||||
}
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (rotateAllRight) {
|
||||
rotateAllRight.addEventListener('click', function () {
|
||||
for (let i = 0; i < pageState.rotations.length; i++) {
|
||||
pageState.rotations[i] = pageState.rotations[i] + 90;
|
||||
}
|
||||
updateAllRotationDisplays();
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(function (f) {
|
||||
return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
||||
});
|
||||
if (pdfFiles.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(pdfFiles[0]);
|
||||
handleFileSelect(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', applyRotations);
|
||||
}
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', applyRotations);
|
||||
}
|
||||
});
|
||||
+155
-699
@@ -1,743 +1,199 @@
|
||||
import { showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes } from '../utils/helpers.js';
|
||||
import { PDFDocument, PDFName } from 'pdf-lib';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import { SanitizePdfState } from '@/types';
|
||||
import { sanitizePdf } from '../utils/sanitize.js';
|
||||
|
||||
const pageState: SanitizePdfState = {
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
};
|
||||
|
||||
function removeMetadataFromDoc(pdfDoc: PDFDocument) {
|
||||
const infoDict = (pdfDoc as any).getInfoDict();
|
||||
const allKeys = infoDict.keys();
|
||||
allKeys.forEach((key: any) => {
|
||||
infoDict.delete(key);
|
||||
});
|
||||
|
||||
pdfDoc.setTitle('');
|
||||
pdfDoc.setAuthor('');
|
||||
pdfDoc.setSubject('');
|
||||
pdfDoc.setKeywords([]);
|
||||
pdfDoc.setCreator('');
|
||||
pdfDoc.setProducer('');
|
||||
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
if (catalogDict.has(PDFName.of('Metadata'))) {
|
||||
catalogDict.delete(PDFName.of('Metadata'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove XMP metadata:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const context = pdfDoc.context;
|
||||
if ((context as any).trailerInfo) {
|
||||
delete (context as any).trailerInfo.ID;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove document IDs:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
if (catalogDict.has(PDFName.of('PieceInfo'))) {
|
||||
catalogDict.delete(PDFName.of('PieceInfo'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove PieceInfo:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove annotations from page:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenFormsInDoc(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
pageState.file = null;
|
||||
pageState.pdfDoc = null;
|
||||
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
async function updateUI() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
|
||||
if (!fileDisplayArea) return;
|
||||
if (!fileDisplayArea) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(pageState.file.size);
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(pageState.file.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 = function () {
|
||||
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 = function () {
|
||||
resetState();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
}
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(files: FileList | null) {
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pageState.file = file;
|
||||
|
||||
// Load PDF for sanitization
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFDocument.load(arrayBuffer);
|
||||
updateUI();
|
||||
} catch (e) {
|
||||
console.error('Error loading PDF:', e);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
}
|
||||
}
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
pageState.file = file;
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sanitizePdf() {
|
||||
if (!pageState.pdfDoc) {
|
||||
showAlert('Error', 'No PDF document loaded.');
|
||||
return;
|
||||
async function runSanitize() {
|
||||
if (!pageState.file) {
|
||||
showAlert('Error', 'No PDF document loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
const loaderModal = document.getElementById('loader-modal');
|
||||
const loaderText = document.getElementById('loader-text');
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
if (loaderText) loaderText.textContent = 'Sanitizing PDF...';
|
||||
|
||||
try {
|
||||
const options = {
|
||||
flattenForms: (
|
||||
document.getElementById('flatten-forms') as HTMLInputElement
|
||||
).checked,
|
||||
removeMetadata: (
|
||||
document.getElementById('remove-metadata') as HTMLInputElement
|
||||
).checked,
|
||||
removeAnnotations: (
|
||||
document.getElementById('remove-annotations') as HTMLInputElement
|
||||
).checked,
|
||||
removeJavascript: (
|
||||
document.getElementById('remove-javascript') as HTMLInputElement
|
||||
).checked,
|
||||
removeEmbeddedFiles: (
|
||||
document.getElementById('remove-embedded-files') as HTMLInputElement
|
||||
).checked,
|
||||
removeLayers: (
|
||||
document.getElementById('remove-layers') as HTMLInputElement
|
||||
).checked,
|
||||
removeLinks: (document.getElementById('remove-links') as HTMLInputElement)
|
||||
.checked,
|
||||
removeStructureTree: (
|
||||
document.getElementById('remove-structure-tree') as HTMLInputElement
|
||||
).checked,
|
||||
removeMarkInfo: (
|
||||
document.getElementById('remove-markinfo') as HTMLInputElement
|
||||
).checked,
|
||||
removeFonts: (document.getElementById('remove-fonts') as HTMLInputElement)
|
||||
.checked,
|
||||
};
|
||||
|
||||
const hasAnyOption = Object.values(options).some(Boolean);
|
||||
if (!hasAnyOption) {
|
||||
showAlert(
|
||||
'No Changes',
|
||||
'No items were selected for removal or none were found in the PDF.'
|
||||
);
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const loaderModal = document.getElementById('loader-modal');
|
||||
const loaderText = document.getElementById('loader-text');
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
if (loaderText) loaderText.textContent = 'Sanitizing PDF...';
|
||||
|
||||
try {
|
||||
const pdfDoc = pageState.pdfDoc;
|
||||
|
||||
const shouldFlattenForms = (document.getElementById('flatten-forms') as HTMLInputElement).checked;
|
||||
const shouldRemoveMetadata = (document.getElementById('remove-metadata') as HTMLInputElement).checked;
|
||||
const shouldRemoveAnnotations = (document.getElementById('remove-annotations') as HTMLInputElement).checked;
|
||||
const shouldRemoveJavascript = (document.getElementById('remove-javascript') as HTMLInputElement).checked;
|
||||
const shouldRemoveEmbeddedFiles = (document.getElementById('remove-embedded-files') as HTMLInputElement).checked;
|
||||
const shouldRemoveLayers = (document.getElementById('remove-layers') as HTMLInputElement).checked;
|
||||
const shouldRemoveLinks = (document.getElementById('remove-links') as HTMLInputElement).checked;
|
||||
const shouldRemoveStructureTree = (document.getElementById('remove-structure-tree') as HTMLInputElement).checked;
|
||||
const shouldRemoveMarkInfo = (document.getElementById('remove-markinfo') as HTMLInputElement).checked;
|
||||
const shouldRemoveFonts = (document.getElementById('remove-fonts') as HTMLInputElement).checked;
|
||||
|
||||
let changesMade = false;
|
||||
|
||||
if (shouldFlattenForms) {
|
||||
try {
|
||||
flattenFormsInDoc(pdfDoc);
|
||||
changesMade = true;
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not flatten forms: ${e.message}`);
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
if (catalogDict.has(PDFName.of('AcroForm'))) {
|
||||
catalogDict.delete(PDFName.of('AcroForm'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (removeError: any) {
|
||||
console.warn('Could not remove AcroForm:', removeError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveMetadata) {
|
||||
removeMetadataFromDoc(pdfDoc);
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (shouldRemoveAnnotations) {
|
||||
removeAnnotationsFromDoc(pdfDoc);
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (shouldRemoveJavascript) {
|
||||
try {
|
||||
if ((pdfDoc as any).javaScripts && (pdfDoc as any).javaScripts.length > 0) {
|
||||
(pdfDoc as any).javaScripts = [];
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
const namesRef = catalogDict.get(PDFName.of('Names'));
|
||||
if (namesRef) {
|
||||
try {
|
||||
const namesDict = pdfDoc.context.lookup(namesRef) as any;
|
||||
if (namesDict.has(PDFName.of('JavaScript'))) {
|
||||
namesDict.delete(PDFName.of('JavaScript'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Names/JavaScript:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('OpenAction'))) {
|
||||
catalogDict.delete(PDFName.of('OpenAction'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('AA'))) {
|
||||
catalogDict.delete(PDFName.of('AA'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
|
||||
if (pageDict.has(PDFName.of('AA'))) {
|
||||
pageDict.delete(PDFName.of('AA'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
const annotRefs = pageDict.Annots()?.asArray() || [];
|
||||
for (const annotRef of annotRefs) {
|
||||
try {
|
||||
const annot = pdfDoc.context.lookup(annotRef) as any;
|
||||
|
||||
if (annot.has(PDFName.of('A'))) {
|
||||
const actionRef = annot.get(PDFName.of('A'));
|
||||
try {
|
||||
const actionDict = pdfDoc.context.lookup(actionRef) as any;
|
||||
const actionType = actionDict
|
||||
.get(PDFName.of('S'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (actionType === 'JavaScript') {
|
||||
annot.delete(PDFName.of('A'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not read action:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (annot.has(PDFName.of('AA'))) {
|
||||
annot.delete(PDFName.of('AA'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process annotation for JS:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove page actions:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const acroFormRef = catalogDict.get(PDFName.of('AcroForm'));
|
||||
if (acroFormRef) {
|
||||
const acroFormDict = pdfDoc.context.lookup(acroFormRef) as any;
|
||||
const fieldsRef = acroFormDict.get(PDFName.of('Fields'));
|
||||
|
||||
if (fieldsRef) {
|
||||
const fieldsArray = pdfDoc.context.lookup(fieldsRef) as any;
|
||||
const fields = fieldsArray.asArray();
|
||||
|
||||
for (const fieldRef of fields) {
|
||||
try {
|
||||
const field = pdfDoc.context.lookup(fieldRef) as any;
|
||||
|
||||
if (field.has(PDFName.of('A'))) {
|
||||
field.delete(PDFName.of('A'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (field.has(PDFName.of('AA'))) {
|
||||
field.delete(PDFName.of('AA'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process field for JS:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process form fields for JS:', e.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove JavaScript: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveEmbeddedFiles) {
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
const namesRef = catalogDict.get(PDFName.of('Names'));
|
||||
if (namesRef) {
|
||||
try {
|
||||
const namesDict = pdfDoc.context.lookup(namesRef) as any;
|
||||
if (namesDict.has(PDFName.of('EmbeddedFiles'))) {
|
||||
namesDict.delete(PDFName.of('EmbeddedFiles'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Names/EmbeddedFiles:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('EmbeddedFiles'))) {
|
||||
catalogDict.delete(PDFName.of('EmbeddedFiles'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const annotRefs = page.node.Annots()?.asArray() || [];
|
||||
const annotsToKeep = [];
|
||||
|
||||
for (const ref of annotRefs) {
|
||||
try {
|
||||
const annot = pdfDoc.context.lookup(ref) as any;
|
||||
const subtype = annot
|
||||
.get(PDFName.of('Subtype'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (subtype !== 'FileAttachment') {
|
||||
annotsToKeep.push(ref);
|
||||
} else {
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e) {
|
||||
annotsToKeep.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (annotsToKeep.length !== annotRefs.length) {
|
||||
if (annotsToKeep.length > 0) {
|
||||
const newAnnotsArray = pdfDoc.context.obj(annotsToKeep);
|
||||
page.node.set(PDFName.of('Annots'), newAnnotsArray);
|
||||
} else {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
}
|
||||
}
|
||||
} catch (pageError: any) {
|
||||
console.warn(
|
||||
`Could not process page for attachments: ${pageError.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((pdfDoc as any).embeddedFiles && (pdfDoc as any).embeddedFiles.length > 0) {
|
||||
(pdfDoc as any).embeddedFiles = [];
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('Collection'))) {
|
||||
catalogDict.delete(PDFName.of('Collection'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove embedded files: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveLayers) {
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
if (catalogDict.has(PDFName.of('OCProperties'))) {
|
||||
catalogDict.delete(PDFName.of('OCProperties'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
|
||||
if (pageDict.has(PDFName.of('OCProperties'))) {
|
||||
pageDict.delete(PDFName.of('OCProperties'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
const resourcesRef = pageDict.get(PDFName.of('Resources'));
|
||||
if (resourcesRef) {
|
||||
try {
|
||||
const resourcesDict = pdfDoc.context.lookup(resourcesRef) as any;
|
||||
if (resourcesDict.has(PDFName.of('Properties'))) {
|
||||
resourcesDict.delete(PDFName.of('Properties'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Resources:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove page layers:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove layers: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveLinks) {
|
||||
try {
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
|
||||
try {
|
||||
const page = pages[pageIndex];
|
||||
const pageDict = page.node;
|
||||
|
||||
const annotsRef = pageDict.get(PDFName.of('Annots'));
|
||||
if (!annotsRef) continue;
|
||||
|
||||
const annotsArray = pdfDoc.context.lookup(annotsRef) as any;
|
||||
const annotRefs = annotsArray.asArray();
|
||||
|
||||
if (annotRefs.length === 0) continue;
|
||||
|
||||
const annotsToKeep = [];
|
||||
let linksRemoved = 0;
|
||||
|
||||
for (const ref of annotRefs) {
|
||||
try {
|
||||
const annot = pdfDoc.context.lookup(ref) as any;
|
||||
const subtype = annot
|
||||
.get(PDFName.of('Subtype'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
let isLink = false;
|
||||
|
||||
if (subtype === 'Link') {
|
||||
isLink = true;
|
||||
linksRemoved++;
|
||||
} else {
|
||||
const actionRef = annot.get(PDFName.of('A'));
|
||||
if (actionRef) {
|
||||
try {
|
||||
const actionDict = pdfDoc.context.lookup(actionRef) as any;
|
||||
const actionType = actionDict
|
||||
.get(PDFName.of('S'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (
|
||||
actionType === 'URI' ||
|
||||
actionType === 'Launch' ||
|
||||
actionType === 'GoTo' ||
|
||||
actionType === 'GoToR'
|
||||
) {
|
||||
isLink = true;
|
||||
linksRemoved++;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not read action:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const dest = annot.get(PDFName.of('Dest'));
|
||||
if (dest && !isLink) {
|
||||
isLink = true;
|
||||
linksRemoved++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLink) {
|
||||
annotsToKeep.push(ref);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process annotation:', e.message);
|
||||
annotsToKeep.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (linksRemoved > 0) {
|
||||
if (annotsToKeep.length > 0) {
|
||||
const newAnnotsArray = pdfDoc.context.obj(annotsToKeep);
|
||||
pageDict.set(PDFName.of('Annots'), newAnnotsArray);
|
||||
} else {
|
||||
pageDict.delete(PDFName.of('Annots'));
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (pageError: any) {
|
||||
console.warn(
|
||||
`Could not process page ${pageIndex + 1} for links: ${pageError.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
const namesRef = catalogDict.get(PDFName.of('Names'));
|
||||
if (namesRef) {
|
||||
try {
|
||||
const namesDict = pdfDoc.context.lookup(namesRef) as any;
|
||||
if (namesDict.has(PDFName.of('Dests'))) {
|
||||
namesDict.delete(PDFName.of('Dests'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Names/Dests:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('Dests'))) {
|
||||
catalogDict.delete(PDFName.of('Dests'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove named destinations:', e.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove links: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveStructureTree) {
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
if (catalogDict.has(PDFName.of('StructTreeRoot'))) {
|
||||
catalogDict.delete(PDFName.of('StructTreeRoot'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
if (pageDict.has(PDFName.of('StructParents'))) {
|
||||
pageDict.delete(PDFName.of('StructParents'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove page StructParents:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('ParentTree'))) {
|
||||
catalogDict.delete(PDFName.of('ParentTree'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove structure tree: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveMarkInfo) {
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
if (catalogDict.has(PDFName.of('MarkInfo'))) {
|
||||
catalogDict.delete(PDFName.of('MarkInfo'));
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('Marked'))) {
|
||||
catalogDict.delete(PDFName.of('Marked'));
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove MarkInfo: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveFonts) {
|
||||
try {
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
|
||||
try {
|
||||
const page = pages[pageIndex];
|
||||
const pageDict = page.node;
|
||||
const resourcesRef = pageDict.get(PDFName.of('Resources'));
|
||||
|
||||
if (resourcesRef) {
|
||||
try {
|
||||
const resourcesDict = pdfDoc.context.lookup(resourcesRef) as any;
|
||||
|
||||
if (resourcesDict.has(PDFName.of('Font'))) {
|
||||
const fontRef = resourcesDict.get(PDFName.of('Font'));
|
||||
|
||||
try {
|
||||
const fontDict = pdfDoc.context.lookup(fontRef) as any;
|
||||
const fontKeys = fontDict.keys();
|
||||
|
||||
for (const fontKey of fontKeys) {
|
||||
try {
|
||||
const specificFontRef = fontDict.get(fontKey);
|
||||
const specificFont =
|
||||
pdfDoc.context.lookup(specificFontRef) as any;
|
||||
|
||||
if (specificFont.has(PDFName.of('FontDescriptor'))) {
|
||||
const descriptorRef = specificFont.get(
|
||||
PDFName.of('FontDescriptor')
|
||||
);
|
||||
const descriptor =
|
||||
pdfDoc.context.lookup(descriptorRef) as any;
|
||||
|
||||
const fontFileKeys = [
|
||||
'FontFile',
|
||||
'FontFile2',
|
||||
'FontFile3',
|
||||
];
|
||||
for (const key of fontFileKeys) {
|
||||
if (descriptor.has(PDFName.of(key))) {
|
||||
descriptor.delete(PDFName.of(key));
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(
|
||||
`Could not process font ${fontKey}:`,
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(
|
||||
'Could not access font dictionary:',
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(
|
||||
'Could not access Resources for fonts:',
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(
|
||||
`Could not remove fonts from page ${pageIndex + 1}:`,
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((pdfDoc as any).fonts && (pdfDoc as any).fonts.length > 0) {
|
||||
(pdfDoc as any).fonts = [];
|
||||
changesMade = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove fonts: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changesMade) {
|
||||
showAlert(
|
||||
'No Changes',
|
||||
'No items were selected for removal or none were found in the PDF.'
|
||||
);
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedPdfBytes = await pdfDoc.save();
|
||||
downloadFile(
|
||||
new Blob([sanitizedPdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
'sanitized.pdf'
|
||||
);
|
||||
showAlert('Success', 'PDF has been sanitized and downloaded.', 'success', () => { resetState(); });
|
||||
} catch (e: any) {
|
||||
console.error('Sanitization Error:', e);
|
||||
showAlert('Error', `An error occurred during sanitization: ${e.message}`);
|
||||
} finally {
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
}
|
||||
const arrayBuffer = await pageState.file.arrayBuffer();
|
||||
const result = await sanitizePdf(new Uint8Array(arrayBuffer), options);
|
||||
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }),
|
||||
'sanitized.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF has been sanitized and downloaded.',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error('Sanitization Error:', e);
|
||||
showAlert('Error', `An error occurred during sanitization: ${e.message}`);
|
||||
} finally {
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
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 processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
handleFileSelect(e.dataTransfer?.files);
|
||||
});
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
handleFileSelect(e.dataTransfer?.files);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', sanitizePdf);
|
||||
}
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', runSanitize);
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../utils/helpers.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { applyScannerEffect } from '../utils/image-effects.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { ScanSettings } from '../types/scanner-effect-type.js';
|
||||
|
||||
@@ -64,143 +65,7 @@ function getSettings(): ScanSettings {
|
||||
};
|
||||
}
|
||||
|
||||
function applyEffects(
|
||||
sourceData: ImageData,
|
||||
canvas: HTMLCanvasElement,
|
||||
settings: ScanSettings,
|
||||
rotationAngle: number,
|
||||
scale: number = 1
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const w = sourceData.width;
|
||||
const h = sourceData.height;
|
||||
|
||||
const scaledBlur = settings.blur * scale;
|
||||
const scaledNoise = settings.noise * scale;
|
||||
|
||||
const workCanvas = document.createElement('canvas');
|
||||
workCanvas.width = w;
|
||||
workCanvas.height = h;
|
||||
const workCtx = workCanvas.getContext('2d')!;
|
||||
|
||||
if (scaledBlur > 0) {
|
||||
workCtx.filter = `blur(${scaledBlur}px)`;
|
||||
}
|
||||
|
||||
workCtx.putImageData(sourceData, 0, 0);
|
||||
if (scaledBlur > 0) {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d')!;
|
||||
tempCtx.filter = `blur(${scaledBlur}px)`;
|
||||
tempCtx.drawImage(workCanvas, 0, 0);
|
||||
workCtx.filter = 'none';
|
||||
workCtx.clearRect(0, 0, w, h);
|
||||
workCtx.drawImage(tempCanvas, 0, 0);
|
||||
}
|
||||
|
||||
const imageData = workCtx.getImageData(0, 0, w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
const contrastFactor =
|
||||
settings.contrast !== 0
|
||||
? (259 * (settings.contrast + 255)) / (255 * (259 - settings.contrast))
|
||||
: 1;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
|
||||
if (settings.grayscale) {
|
||||
const grey = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
r = grey;
|
||||
g = grey;
|
||||
b = grey;
|
||||
}
|
||||
|
||||
if (settings.brightness !== 0) {
|
||||
r += settings.brightness;
|
||||
g += settings.brightness;
|
||||
b += settings.brightness;
|
||||
}
|
||||
|
||||
if (settings.contrast !== 0) {
|
||||
r = contrastFactor * (r - 128) + 128;
|
||||
g = contrastFactor * (g - 128) + 128;
|
||||
b = contrastFactor * (b - 128) + 128;
|
||||
}
|
||||
|
||||
if (settings.yellowish > 0) {
|
||||
const intensity = settings.yellowish / 50;
|
||||
r += 20 * intensity;
|
||||
g += 12 * intensity;
|
||||
b -= 15 * intensity;
|
||||
}
|
||||
|
||||
if (scaledNoise > 0) {
|
||||
const n = (Math.random() - 0.5) * scaledNoise;
|
||||
r += n;
|
||||
g += n;
|
||||
b += n;
|
||||
}
|
||||
|
||||
data[i] = Math.max(0, Math.min(255, r));
|
||||
data[i + 1] = Math.max(0, Math.min(255, g));
|
||||
data[i + 2] = Math.max(0, Math.min(255, b));
|
||||
}
|
||||
|
||||
workCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
if (settings.border) {
|
||||
const borderSize = Math.max(w, h) * 0.02;
|
||||
const gradient1 = workCtx.createLinearGradient(0, 0, borderSize, 0);
|
||||
gradient1.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient1.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient1;
|
||||
workCtx.fillRect(0, 0, borderSize, h);
|
||||
|
||||
const gradient2 = workCtx.createLinearGradient(w, 0, w - borderSize, 0);
|
||||
gradient2.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient2.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient2;
|
||||
workCtx.fillRect(w - borderSize, 0, borderSize, h);
|
||||
|
||||
const gradient3 = workCtx.createLinearGradient(0, 0, 0, borderSize);
|
||||
gradient3.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient3.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient3;
|
||||
workCtx.fillRect(0, 0, w, borderSize);
|
||||
|
||||
const gradient4 = workCtx.createLinearGradient(0, h, 0, h - borderSize);
|
||||
gradient4.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient4.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient4;
|
||||
workCtx.fillRect(0, h - borderSize, w, borderSize);
|
||||
}
|
||||
|
||||
if (rotationAngle !== 0) {
|
||||
const rad = (rotationAngle * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rad));
|
||||
const sin = Math.abs(Math.sin(rad));
|
||||
const newW = Math.ceil(w * cos + h * sin);
|
||||
const newH = Math.ceil(w * sin + h * cos);
|
||||
|
||||
canvas.width = newW;
|
||||
canvas.height = newH;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, newW, newH);
|
||||
ctx.translate(newW / 2, newH / 2);
|
||||
ctx.rotate(rad);
|
||||
ctx.drawImage(workCanvas, -w / 2, -h / 2);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
} else {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.drawImage(workCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
const applyEffects = applyScannerEffect;
|
||||
|
||||
function updatePreview(): void {
|
||||
if (!cachedBaselineData) return;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getPDFDocument } from './helpers.js';
|
||||
import { loadPyMuPDF } from './pymupdf-loader.js';
|
||||
|
||||
export const CONDENSE_PRESETS = {
|
||||
light: {
|
||||
images: { quality: 90, dpiTarget: 150, dpiThreshold: 200 },
|
||||
scrub: { metadata: false, thumbnails: true },
|
||||
subsetFonts: true,
|
||||
},
|
||||
balanced: {
|
||||
images: { quality: 75, dpiTarget: 96, dpiThreshold: 150 },
|
||||
scrub: { metadata: true, thumbnails: true },
|
||||
subsetFonts: true,
|
||||
},
|
||||
aggressive: {
|
||||
images: { quality: 50, dpiTarget: 72, dpiThreshold: 100 },
|
||||
scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
|
||||
subsetFonts: true,
|
||||
},
|
||||
extreme: {
|
||||
images: { quality: 30, dpiTarget: 60, dpiThreshold: 96 },
|
||||
scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
|
||||
subsetFonts: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PHOTON_PRESETS = {
|
||||
light: { scale: 2.0, quality: 0.85 },
|
||||
balanced: { scale: 1.5, quality: 0.65 },
|
||||
aggressive: { scale: 1.2, quality: 0.45 },
|
||||
extreme: { scale: 1.0, quality: 0.25 },
|
||||
};
|
||||
|
||||
export interface CondenseCustomSettings {
|
||||
imageQuality?: number;
|
||||
dpiTarget?: number;
|
||||
dpiThreshold?: number;
|
||||
removeMetadata?: boolean;
|
||||
subsetFonts?: boolean;
|
||||
convertToGrayscale?: boolean;
|
||||
removeThumbnails?: boolean;
|
||||
}
|
||||
|
||||
export async function performCondenseCompression(
|
||||
fileBlob: Blob,
|
||||
level: string,
|
||||
customSettings?: CondenseCustomSettings
|
||||
) {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
|
||||
const preset =
|
||||
CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] ||
|
||||
CONDENSE_PRESETS.balanced;
|
||||
|
||||
const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget;
|
||||
const userThreshold =
|
||||
customSettings?.dpiThreshold ?? preset.images.dpiThreshold;
|
||||
const dpiThreshold = Math.max(userThreshold, dpiTarget + 10);
|
||||
|
||||
const options = {
|
||||
images: {
|
||||
enabled: true,
|
||||
quality: customSettings?.imageQuality ?? preset.images.quality,
|
||||
dpiTarget,
|
||||
dpiThreshold,
|
||||
convertToGray: customSettings?.convertToGrayscale ?? false,
|
||||
},
|
||||
scrub: {
|
||||
metadata: customSettings?.removeMetadata ?? preset.scrub.metadata,
|
||||
thumbnails: customSettings?.removeThumbnails ?? preset.scrub.thumbnails,
|
||||
xmlMetadata: (preset.scrub as any).xmlMetadata ?? false,
|
||||
},
|
||||
subsetFonts: customSettings?.subsetFonts ?? preset.subsetFonts,
|
||||
save: {
|
||||
garbage: 4 as const,
|
||||
deflate: true,
|
||||
clean: true,
|
||||
useObjstms: true,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await pymupdf.compressPdf(fileBlob, options);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.message || String(error);
|
||||
if (
|
||||
errorMessage.includes('PatternType') ||
|
||||
errorMessage.includes('pattern')
|
||||
) {
|
||||
const fallbackOptions = {
|
||||
...options,
|
||||
images: {
|
||||
...options.images,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
|
||||
return { ...result, usedFallback: true };
|
||||
}
|
||||
|
||||
throw new Error(`PDF compression failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function performPhotonCompression(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
level: string
|
||||
): Promise<Uint8Array> {
|
||||
const pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const settings =
|
||||
PHOTON_PRESETS[level as keyof typeof PHOTON_PRESETS] ||
|
||||
PHOTON_PRESETS.balanced;
|
||||
|
||||
for (let i = 1; i <= pdfJsDoc.numPages; i++) {
|
||||
const page = await pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: settings.scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Failed to create canvas context');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas: canvas })
|
||||
.promise;
|
||||
|
||||
const jpegBlob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error('Failed to create JPEG blob'));
|
||||
},
|
||||
'image/jpeg',
|
||||
settings.quality
|
||||
)
|
||||
);
|
||||
|
||||
// Release canvas memory
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
const jpegBytes = await jpegBlob.arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
}
|
||||
return await newPdfDoc.save();
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { ScanSettings } from '../types/scanner-effect-type.js';
|
||||
import type { AdjustColorsSettings } from '../types/adjust-colors-type.js';
|
||||
|
||||
export function applyGreyscale(imageData: ImageData): void {
|
||||
const data = imageData.data;
|
||||
for (let j = 0; j < data.length; j += 4) {
|
||||
const grey = Math.round(
|
||||
0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2]
|
||||
);
|
||||
data[j] = grey;
|
||||
data[j + 1] = grey;
|
||||
data[j + 2] = grey;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyInvertColors(imageData: ImageData): void {
|
||||
const data = imageData.data;
|
||||
for (let j = 0; j < data.length; j += 4) {
|
||||
data[j] = 255 - data[j];
|
||||
data[j + 1] = 255 - data[j + 1];
|
||||
data[j + 2] = 255 - data[j + 2];
|
||||
}
|
||||
}
|
||||
|
||||
export function rgbToHsl(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
export function hslToRgb(
|
||||
h: number,
|
||||
s: number,
|
||||
l: number
|
||||
): [number, number, number] {
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255);
|
||||
return [v, v, v];
|
||||
}
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return [
|
||||
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
Math.round(hue2rgb(p, q, h) * 255),
|
||||
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
export function applyScannerEffect(
|
||||
sourceData: ImageData,
|
||||
canvas: HTMLCanvasElement,
|
||||
settings: ScanSettings,
|
||||
rotationAngle: number,
|
||||
scale: number = 1
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const w = sourceData.width;
|
||||
const h = sourceData.height;
|
||||
|
||||
const scaledBlur = settings.blur * scale;
|
||||
const scaledNoise = settings.noise * scale;
|
||||
|
||||
const workCanvas = document.createElement('canvas');
|
||||
workCanvas.width = w;
|
||||
workCanvas.height = h;
|
||||
const workCtx = workCanvas.getContext('2d')!;
|
||||
|
||||
if (scaledBlur > 0) {
|
||||
workCtx.filter = `blur(${scaledBlur}px)`;
|
||||
}
|
||||
|
||||
workCtx.putImageData(sourceData, 0, 0);
|
||||
if (scaledBlur > 0) {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d')!;
|
||||
tempCtx.filter = `blur(${scaledBlur}px)`;
|
||||
tempCtx.drawImage(workCanvas, 0, 0);
|
||||
workCtx.filter = 'none';
|
||||
workCtx.clearRect(0, 0, w, h);
|
||||
workCtx.drawImage(tempCanvas, 0, 0);
|
||||
}
|
||||
|
||||
const imageData = workCtx.getImageData(0, 0, w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
const contrastFactor =
|
||||
settings.contrast !== 0
|
||||
? (259 * (settings.contrast + 255)) / (255 * (259 - settings.contrast))
|
||||
: 1;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
|
||||
if (settings.grayscale) {
|
||||
const grey = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
r = grey;
|
||||
g = grey;
|
||||
b = grey;
|
||||
}
|
||||
|
||||
if (settings.brightness !== 0) {
|
||||
r += settings.brightness;
|
||||
g += settings.brightness;
|
||||
b += settings.brightness;
|
||||
}
|
||||
|
||||
if (settings.contrast !== 0) {
|
||||
r = contrastFactor * (r - 128) + 128;
|
||||
g = contrastFactor * (g - 128) + 128;
|
||||
b = contrastFactor * (b - 128) + 128;
|
||||
}
|
||||
|
||||
if (settings.yellowish > 0) {
|
||||
const intensity = settings.yellowish / 50;
|
||||
r += 20 * intensity;
|
||||
g += 12 * intensity;
|
||||
b -= 15 * intensity;
|
||||
}
|
||||
|
||||
if (scaledNoise > 0) {
|
||||
const n = (Math.random() - 0.5) * scaledNoise;
|
||||
r += n;
|
||||
g += n;
|
||||
b += n;
|
||||
}
|
||||
|
||||
data[i] = Math.max(0, Math.min(255, r));
|
||||
data[i + 1] = Math.max(0, Math.min(255, g));
|
||||
data[i + 2] = Math.max(0, Math.min(255, b));
|
||||
}
|
||||
|
||||
workCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
if (settings.border) {
|
||||
const borderSize = Math.max(w, h) * 0.02;
|
||||
const gradient1 = workCtx.createLinearGradient(0, 0, borderSize, 0);
|
||||
gradient1.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient1.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient1;
|
||||
workCtx.fillRect(0, 0, borderSize, h);
|
||||
|
||||
const gradient2 = workCtx.createLinearGradient(w, 0, w - borderSize, 0);
|
||||
gradient2.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient2.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient2;
|
||||
workCtx.fillRect(w - borderSize, 0, borderSize, h);
|
||||
|
||||
const gradient3 = workCtx.createLinearGradient(0, 0, 0, borderSize);
|
||||
gradient3.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient3.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient3;
|
||||
workCtx.fillRect(0, 0, w, borderSize);
|
||||
|
||||
const gradient4 = workCtx.createLinearGradient(0, h, 0, h - borderSize);
|
||||
gradient4.addColorStop(0, 'rgba(0,0,0,0.3)');
|
||||
gradient4.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
workCtx.fillStyle = gradient4;
|
||||
workCtx.fillRect(0, h - borderSize, w, borderSize);
|
||||
}
|
||||
|
||||
if (rotationAngle !== 0) {
|
||||
const rad = (rotationAngle * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rad));
|
||||
const sin = Math.abs(Math.sin(rad));
|
||||
const newW = Math.ceil(w * cos + h * sin);
|
||||
const newH = Math.ceil(w * sin + h * cos);
|
||||
|
||||
canvas.width = newW;
|
||||
canvas.height = newH;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, newW, newH);
|
||||
ctx.translate(newW / 2, newH / 2);
|
||||
ctx.rotate(rad);
|
||||
ctx.drawImage(workCanvas, -w / 2, -h / 2);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
} else {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.drawImage(workCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyColorAdjustments(
|
||||
sourceData: ImageData,
|
||||
canvas: HTMLCanvasElement,
|
||||
settings: AdjustColorsSettings
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const w = sourceData.width;
|
||||
const h = sourceData.height;
|
||||
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
|
||||
const imageData = new ImageData(new Uint8ClampedArray(sourceData.data), w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
const contrastFactor =
|
||||
settings.contrast !== 0
|
||||
? (259 * (settings.contrast + 255)) / (255 * (259 - settings.contrast))
|
||||
: 1;
|
||||
|
||||
const gammaCorrection = settings.gamma !== 1.0 ? 1 / settings.gamma : 1;
|
||||
const sepiaAmount = settings.sepia / 100;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
|
||||
if (settings.brightness !== 0) {
|
||||
const adj = settings.brightness * 2.55;
|
||||
r += adj;
|
||||
g += adj;
|
||||
b += adj;
|
||||
}
|
||||
|
||||
if (settings.contrast !== 0) {
|
||||
r = contrastFactor * (r - 128) + 128;
|
||||
g = contrastFactor * (g - 128) + 128;
|
||||
b = contrastFactor * (b - 128) + 128;
|
||||
}
|
||||
|
||||
if (settings.saturation !== 0 || settings.hueShift !== 0) {
|
||||
const [hue, sat, lig] = rgbToHsl(
|
||||
Math.max(0, Math.min(255, r)),
|
||||
Math.max(0, Math.min(255, g)),
|
||||
Math.max(0, Math.min(255, b))
|
||||
);
|
||||
|
||||
let newHue = hue;
|
||||
if (settings.hueShift !== 0) {
|
||||
newHue = (hue + settings.hueShift / 360) % 1;
|
||||
if (newHue < 0) newHue += 1;
|
||||
}
|
||||
|
||||
let newSat = sat;
|
||||
if (settings.saturation !== 0) {
|
||||
const satAdj = settings.saturation / 100;
|
||||
newSat = satAdj > 0 ? sat + (1 - sat) * satAdj : sat * (1 + satAdj);
|
||||
newSat = Math.max(0, Math.min(1, newSat));
|
||||
}
|
||||
|
||||
[r, g, b] = hslToRgb(newHue, newSat, lig);
|
||||
}
|
||||
|
||||
if (settings.temperature !== 0) {
|
||||
const t = settings.temperature / 50;
|
||||
r += 30 * t;
|
||||
b -= 30 * t;
|
||||
}
|
||||
|
||||
if (settings.tint !== 0) {
|
||||
const t = settings.tint / 50;
|
||||
g += 30 * t;
|
||||
}
|
||||
|
||||
if (settings.gamma !== 1.0) {
|
||||
r = Math.pow(Math.max(0, Math.min(255, r)) / 255, gammaCorrection) * 255;
|
||||
g = Math.pow(Math.max(0, Math.min(255, g)) / 255, gammaCorrection) * 255;
|
||||
b = Math.pow(Math.max(0, Math.min(255, b)) / 255, gammaCorrection) * 255;
|
||||
}
|
||||
|
||||
if (settings.sepia > 0) {
|
||||
const sr = 0.393 * r + 0.769 * g + 0.189 * b;
|
||||
const sg = 0.349 * r + 0.686 * g + 0.168 * b;
|
||||
const sb = 0.272 * r + 0.534 * g + 0.131 * b;
|
||||
r = r + (sr - r) * sepiaAmount;
|
||||
g = g + (sg - g) * sepiaAmount;
|
||||
b = b + (sb - b) * sepiaAmount;
|
||||
}
|
||||
|
||||
data[i] = Math.max(0, Math.min(255, r));
|
||||
data[i + 1] = Math.max(0, Math.min(255, g));
|
||||
data[i + 2] = Math.max(0, Math.min(255, b));
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import Tesseract from 'tesseract.js';
|
||||
import { PDFDocument, StandardFonts, rgb, PDFFont } from 'pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { getFontForLanguage } from './font-loader.js';
|
||||
import { OcrPage, OcrLine } from '@/types';
|
||||
import {
|
||||
parseHocrDocument,
|
||||
calculateWordTransform,
|
||||
calculateSpaceTransform,
|
||||
} from './hocr-transform.js';
|
||||
import { getPDFDocument } from './helpers.js';
|
||||
|
||||
export interface OcrOptions {
|
||||
language: string;
|
||||
resolution: number;
|
||||
binarize: boolean;
|
||||
whitelist: string;
|
||||
onProgress?: (status: string, progress: number) => void;
|
||||
}
|
||||
|
||||
export interface OcrResult {
|
||||
pdfBytes: Uint8Array;
|
||||
pdfDoc: PDFDocument;
|
||||
fullText: string;
|
||||
}
|
||||
|
||||
function binarizeCanvas(ctx: CanvasRenderingContext2D) {
|
||||
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const brightness =
|
||||
0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||
const color = brightness > 128 ? 255 : 0;
|
||||
data[i] = data[i + 1] = data[i + 2] = color;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function drawOcrTextLayer(
|
||||
page: ReturnType<typeof PDFDocument.prototype.addPage>,
|
||||
ocrPage: OcrPage,
|
||||
pageHeight: number,
|
||||
primaryFont: PDFFont,
|
||||
latinFont: PDFFont
|
||||
): void {
|
||||
ocrPage.lines.forEach(function (line: OcrLine) {
|
||||
const words = line.words;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
const text = word.text.replace(
|
||||
/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g,
|
||||
''
|
||||
);
|
||||
|
||||
if (!text.trim()) continue;
|
||||
|
||||
const hasNonLatin = /[^\u0000-\u007F]/.test(text);
|
||||
const font = hasNonLatin ? primaryFont : latinFont;
|
||||
|
||||
if (!font) {
|
||||
console.warn('Font not available for text: "' + text + '"');
|
||||
continue;
|
||||
}
|
||||
|
||||
const transform = calculateWordTransform(
|
||||
word,
|
||||
line,
|
||||
pageHeight,
|
||||
(txt: string, size: number) => {
|
||||
try {
|
||||
return font.widthOfTextAtSize(txt, size);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (transform.fontSize <= 0) continue;
|
||||
|
||||
try {
|
||||
page.drawText(text, {
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
font,
|
||||
size: transform.fontSize,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Could not draw text "${text}":`, error);
|
||||
}
|
||||
|
||||
if (line.injectWordBreaks && i < words.length - 1) {
|
||||
const nextWord = words[i + 1];
|
||||
const spaceTransform = calculateSpaceTransform(
|
||||
word,
|
||||
nextWord,
|
||||
line,
|
||||
pageHeight,
|
||||
(size: number) => {
|
||||
try {
|
||||
return font.widthOfTextAtSize(' ', size);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (spaceTransform && spaceTransform.horizontalScale > 0.1) {
|
||||
try {
|
||||
page.drawText(' ', {
|
||||
x: spaceTransform.x,
|
||||
y: spaceTransform.y,
|
||||
font,
|
||||
size: spaceTransform.fontSize,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
});
|
||||
} catch {
|
||||
console.warn(`Could not draw space between words`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function performOcr(
|
||||
pdfBytes: Uint8Array | ArrayBuffer,
|
||||
options: OcrOptions
|
||||
): Promise<OcrResult> {
|
||||
const { language, resolution, binarize, whitelist, onProgress } = options;
|
||||
const progress = onProgress || (() => {});
|
||||
|
||||
const worker = await Tesseract.createWorker(language, 1, {
|
||||
logger: function (m: { status: string; progress: number }) {
|
||||
progress(m.status, m.progress || 0);
|
||||
},
|
||||
});
|
||||
|
||||
await worker.setParameters({
|
||||
tessjs_create_hocr: '1',
|
||||
tessedit_pageseg_mode: Tesseract.PSM.AUTO,
|
||||
});
|
||||
|
||||
if (whitelist) {
|
||||
await worker.setParameters({
|
||||
tessedit_char_whitelist: whitelist,
|
||||
});
|
||||
}
|
||||
|
||||
const pdf = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
|
||||
newPdfDoc.registerFontkit(fontkit);
|
||||
|
||||
progress('Loading fonts...', 0);
|
||||
|
||||
const selectedLangs = language.split('+');
|
||||
const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
|
||||
const indicLangs = [
|
||||
'hin',
|
||||
'ben',
|
||||
'guj',
|
||||
'kan',
|
||||
'mal',
|
||||
'ori',
|
||||
'pan',
|
||||
'tam',
|
||||
'tel',
|
||||
'sin',
|
||||
];
|
||||
const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
|
||||
|
||||
const primaryLang =
|
||||
selectedLangs.find((l) => priorityLangs.includes(l)) ||
|
||||
selectedLangs[0] ||
|
||||
'eng';
|
||||
|
||||
const hasCJK = selectedLangs.some((l) => cjkLangs.includes(l));
|
||||
const hasIndic = selectedLangs.some((l) => indicLangs.includes(l));
|
||||
const hasLatin =
|
||||
selectedLangs.some((l) => !priorityLangs.includes(l)) ||
|
||||
selectedLangs.includes('eng');
|
||||
const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
|
||||
|
||||
let primaryFont: PDFFont;
|
||||
let latinFont: PDFFont;
|
||||
|
||||
try {
|
||||
if (isIndicPlusLatin) {
|
||||
const [scriptFontBytes, latinFontBytes] = await Promise.all([
|
||||
getFontForLanguage(primaryLang),
|
||||
getFontForLanguage('eng'),
|
||||
]);
|
||||
primaryFont = await newPdfDoc.embedFont(scriptFontBytes, {
|
||||
subset: false,
|
||||
});
|
||||
latinFont = await newPdfDoc.embedFont(latinFontBytes, {
|
||||
subset: false,
|
||||
});
|
||||
} else {
|
||||
const fontBytes = await getFontForLanguage(primaryLang);
|
||||
primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false });
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Font loading failed, falling back to Helvetica', e);
|
||||
primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
|
||||
let fullText = '';
|
||||
|
||||
try {
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
progress(
|
||||
`Processing page ${i} of ${pdf.numPages}`,
|
||||
(i - 1) / pdf.numPages
|
||||
);
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: resolution });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Failed to create canvas context');
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
|
||||
if (binarize) {
|
||||
binarizeCanvas(context);
|
||||
}
|
||||
|
||||
const result = await worker.recognize(
|
||||
canvas,
|
||||
{},
|
||||
{ text: true, hocr: true }
|
||||
);
|
||||
const data = result.data;
|
||||
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
|
||||
const pngImageBytes = await new Promise<Uint8Array>(function (
|
||||
resolve,
|
||||
reject
|
||||
) {
|
||||
canvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to create image blob'));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
reader.onerror = function () {
|
||||
reject(new Error('Failed to read image data'));
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
// Release canvas memory
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
const pngImage = await newPdfDoc.embedPng(pngImageBytes);
|
||||
newPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
if (data.hocr) {
|
||||
const ocrPage = parseHocrDocument(data.hocr);
|
||||
drawOcrTextLayer(
|
||||
newPage,
|
||||
ocrPage,
|
||||
viewport.height,
|
||||
primaryFont,
|
||||
latinFont
|
||||
);
|
||||
}
|
||||
|
||||
fullText += data.text + '\n\n';
|
||||
}
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
|
||||
const savedBytes = await newPdfDoc.save();
|
||||
|
||||
return {
|
||||
pdfBytes: new Uint8Array(savedBytes),
|
||||
pdfDoc: newPdfDoc,
|
||||
fullText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { PDFDocument, degrees, rgb, StandardFonts } from 'pdf-lib';
|
||||
|
||||
export async function mergePdfs(
|
||||
pdfBytesList: Uint8Array[]
|
||||
): Promise<Uint8Array> {
|
||||
const mergedDoc = await PDFDocument.create();
|
||||
for (const bytes of pdfBytesList) {
|
||||
const srcDoc = await PDFDocument.load(bytes);
|
||||
const copiedPages = await mergedDoc.copyPages(
|
||||
srcDoc,
|
||||
srcDoc.getPageIndices()
|
||||
);
|
||||
copiedPages.forEach((page) => mergedDoc.addPage(page));
|
||||
}
|
||||
return new Uint8Array(await mergedDoc.save());
|
||||
}
|
||||
|
||||
export async function splitPdf(
|
||||
pdfBytes: Uint8Array,
|
||||
pageIndices: number[]
|
||||
): Promise<Uint8Array> {
|
||||
const srcDoc = await PDFDocument.load(pdfBytes);
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(srcDoc, pageIndices);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
return new Uint8Array(await newPdf.save());
|
||||
}
|
||||
|
||||
export async function rotatePdfUniform(
|
||||
pdfBytes: Uint8Array,
|
||||
angle: number
|
||||
): Promise<Uint8Array> {
|
||||
const srcDoc = await PDFDocument.load(pdfBytes);
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const pageCount = srcDoc.getPageCount();
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const originalPage = srcDoc.getPage(i);
|
||||
const currentRotation = originalPage.getRotation().angle;
|
||||
const totalRotation = currentRotation + angle;
|
||||
|
||||
if (totalRotation % 90 === 0) {
|
||||
const [copiedPage] = await newPdfDoc.copyPages(srcDoc, [i]);
|
||||
copiedPage.setRotation(degrees(totalRotation));
|
||||
newPdfDoc.addPage(copiedPage);
|
||||
} else {
|
||||
const embeddedPage = await newPdfDoc.embedPage(originalPage);
|
||||
const { width, height } = embeddedPage.scale(1);
|
||||
const angleRad = (totalRotation * Math.PI) / 180;
|
||||
const absCos = Math.abs(Math.cos(angleRad));
|
||||
const absSin = Math.abs(Math.sin(angleRad));
|
||||
const newWidth = width * absCos + height * absSin;
|
||||
const newHeight = width * absSin + height * absCos;
|
||||
const newPage = newPdfDoc.addPage([newWidth, newHeight]);
|
||||
const x =
|
||||
newWidth / 2 -
|
||||
((width / 2) * Math.cos(angleRad) - (height / 2) * Math.sin(angleRad));
|
||||
const y =
|
||||
newHeight / 2 -
|
||||
((width / 2) * Math.sin(angleRad) + (height / 2) * Math.cos(angleRad));
|
||||
newPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotate: degrees(totalRotation),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(await newPdfDoc.save());
|
||||
}
|
||||
|
||||
export async function rotatePdfPages(
|
||||
pdfBytes: Uint8Array,
|
||||
rotations: number[]
|
||||
): Promise<Uint8Array> {
|
||||
const srcDoc = await PDFDocument.load(pdfBytes);
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const pageCount = srcDoc.getPageCount();
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const rotation = rotations[i] || 0;
|
||||
const originalPage = srcDoc.getPage(i);
|
||||
const currentRotation = originalPage.getRotation().angle;
|
||||
const totalRotation = currentRotation + rotation;
|
||||
|
||||
if (totalRotation % 90 === 0) {
|
||||
const [copiedPage] = await newPdfDoc.copyPages(srcDoc, [i]);
|
||||
copiedPage.setRotation(degrees(totalRotation));
|
||||
newPdfDoc.addPage(copiedPage);
|
||||
} else {
|
||||
const embeddedPage = await newPdfDoc.embedPage(originalPage);
|
||||
const { width, height } = embeddedPage.scale(1);
|
||||
const angleRad = (totalRotation * Math.PI) / 180;
|
||||
const absCos = Math.abs(Math.cos(angleRad));
|
||||
const absSin = Math.abs(Math.sin(angleRad));
|
||||
const newWidth = width * absCos + height * absSin;
|
||||
const newHeight = width * absSin + height * absCos;
|
||||
const newPage = newPdfDoc.addPage([newWidth, newHeight]);
|
||||
const x =
|
||||
newWidth / 2 -
|
||||
((width / 2) * Math.cos(angleRad) - (height / 2) * Math.sin(angleRad));
|
||||
const y =
|
||||
newHeight / 2 -
|
||||
((width / 2) * Math.sin(angleRad) + (height / 2) * Math.cos(angleRad));
|
||||
newPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotate: degrees(totalRotation),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(await newPdfDoc.save());
|
||||
}
|
||||
|
||||
export async function deletePdfPages(
|
||||
pdfBytes: Uint8Array,
|
||||
pagesToDelete: Set<number>
|
||||
): Promise<Uint8Array> {
|
||||
const srcDoc = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = srcDoc.getPageCount();
|
||||
|
||||
const pagesToKeep: number[] = [];
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
if (!pagesToDelete.has(i + 1)) {
|
||||
pagesToKeep.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (pagesToKeep.length === 0) throw new Error('Cannot delete all pages');
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(srcDoc, pagesToKeep);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
return new Uint8Array(await newPdf.save());
|
||||
}
|
||||
|
||||
export function parsePageRange(rangeStr: string, totalPages: number): number[] {
|
||||
const indices: Set<number> = new Set();
|
||||
const parts = rangeStr.split(',').map((s) => s.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.includes('-')) {
|
||||
const [startStr, endStr] = part.split('-');
|
||||
const start = Math.max(1, parseInt(startStr, 10) || 1);
|
||||
const end = Math.min(totalPages, parseInt(endStr, 10) || totalPages);
|
||||
for (let i = start; i <= end; i++) {
|
||||
indices.add(i - 1);
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(part, 10);
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
indices.add(page - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(indices).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function parseDeletePages(str: string, totalPages: number): Set<number> {
|
||||
const pages = new Set<number>();
|
||||
const parts = str.split(',').map((s) => s.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.includes('-')) {
|
||||
const [startStr, endStr] = part.split('-');
|
||||
const start = Math.max(1, parseInt(startStr, 10) || 1);
|
||||
const end = Math.min(totalPages, parseInt(endStr, 10) || totalPages);
|
||||
for (let i = start; i <= end; i++) pages.add(i);
|
||||
} else {
|
||||
const page = parseInt(part, 10);
|
||||
if (page >= 1 && page <= totalPages) pages.add(page);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
export interface TextWatermarkOptions {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
opacity: number;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export async function addTextWatermark(
|
||||
pdfBytes: Uint8Array,
|
||||
options: TextWatermarkOptions
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const page of pages) {
|
||||
const { width, height } = page.getSize();
|
||||
const textWidth = font.widthOfTextAtSize(options.text, options.fontSize);
|
||||
|
||||
page.drawText(options.text, {
|
||||
x: (width - textWidth) / 2,
|
||||
y: height / 2,
|
||||
font,
|
||||
size: options.fontSize,
|
||||
color: rgb(options.color.r, options.color.g, options.color.b),
|
||||
opacity: options.opacity,
|
||||
rotate: degrees(options.angle),
|
||||
});
|
||||
}
|
||||
|
||||
return new Uint8Array(await pdfDoc.save());
|
||||
}
|
||||
|
||||
export interface ImageWatermarkOptions {
|
||||
imageBytes: Uint8Array;
|
||||
imageType: 'png' | 'jpg';
|
||||
opacity: number;
|
||||
angle: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export async function addImageWatermark(
|
||||
pdfBytes: Uint8Array,
|
||||
options: ImageWatermarkOptions
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const image =
|
||||
options.imageType === 'png'
|
||||
? await pdfDoc.embedPng(options.imageBytes)
|
||||
: await pdfDoc.embedJpg(options.imageBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const page of pages) {
|
||||
const { width, height } = page.getSize();
|
||||
const imgWidth = image.width * options.scale;
|
||||
const imgHeight = image.height * options.scale;
|
||||
|
||||
page.drawImage(image, {
|
||||
x: (width - imgWidth) / 2,
|
||||
y: (height - imgHeight) / 2,
|
||||
width: imgWidth,
|
||||
height: imgHeight,
|
||||
opacity: options.opacity,
|
||||
rotate: degrees(options.angle),
|
||||
});
|
||||
}
|
||||
|
||||
return new Uint8Array(await pdfDoc.save());
|
||||
}
|
||||
|
||||
export type PageNumberPosition =
|
||||
| 'bottom-center'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'top-center'
|
||||
| 'top-left'
|
||||
| 'top-right';
|
||||
export type PageNumberFormat = 'simple' | 'page_x_of_y';
|
||||
|
||||
export interface PageNumberOptions {
|
||||
position: PageNumberPosition;
|
||||
fontSize: number;
|
||||
format: PageNumberFormat;
|
||||
color: { r: number; g: number; b: number };
|
||||
}
|
||||
|
||||
export async function addPageNumbers(
|
||||
pdfBytes: Uint8Array,
|
||||
options: PageNumberOptions
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const pages = pdfDoc.getPages();
|
||||
const totalPages = pages.length;
|
||||
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
const page = pages[i];
|
||||
const mediaBox = page.getMediaBox();
|
||||
const cropBox = page.getCropBox();
|
||||
const bounds = cropBox || mediaBox;
|
||||
const width = bounds.width;
|
||||
const height = bounds.height;
|
||||
const xOffset = bounds.x || 0;
|
||||
const yOffset = bounds.y || 0;
|
||||
|
||||
const pageNumText =
|
||||
options.format === 'page_x_of_y'
|
||||
? `${i + 1} / ${totalPages}`
|
||||
: `${i + 1}`;
|
||||
|
||||
const textWidth = helveticaFont.widthOfTextAtSize(
|
||||
pageNumText,
|
||||
options.fontSize
|
||||
);
|
||||
const textHeight = options.fontSize;
|
||||
|
||||
const minMargin = 8;
|
||||
const maxMargin = 40;
|
||||
const marginPercentage = 0.04;
|
||||
|
||||
const horizontalMargin = Math.max(
|
||||
minMargin,
|
||||
Math.min(maxMargin, width * marginPercentage)
|
||||
);
|
||||
const verticalMargin = Math.max(
|
||||
minMargin,
|
||||
Math.min(maxMargin, height * marginPercentage)
|
||||
);
|
||||
|
||||
const safeHorizontalMargin = Math.max(horizontalMargin, textWidth / 2 + 3);
|
||||
const safeVerticalMargin = Math.max(verticalMargin, textHeight + 3);
|
||||
|
||||
let x = 0,
|
||||
y = 0;
|
||||
|
||||
switch (options.position) {
|
||||
case 'bottom-center':
|
||||
x =
|
||||
Math.max(
|
||||
safeHorizontalMargin,
|
||||
Math.min(
|
||||
width - safeHorizontalMargin - textWidth,
|
||||
(width - textWidth) / 2
|
||||
)
|
||||
) + xOffset;
|
||||
y = safeVerticalMargin + yOffset;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = safeHorizontalMargin + xOffset;
|
||||
y = safeVerticalMargin + yOffset;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x =
|
||||
Math.max(
|
||||
safeHorizontalMargin,
|
||||
width - safeHorizontalMargin - textWidth
|
||||
) + xOffset;
|
||||
y = safeVerticalMargin + yOffset;
|
||||
break;
|
||||
case 'top-center':
|
||||
x =
|
||||
Math.max(
|
||||
safeHorizontalMargin,
|
||||
Math.min(
|
||||
width - safeHorizontalMargin - textWidth,
|
||||
(width - textWidth) / 2
|
||||
)
|
||||
) + xOffset;
|
||||
y = height - safeVerticalMargin - textHeight + yOffset;
|
||||
break;
|
||||
case 'top-left':
|
||||
x = safeHorizontalMargin + xOffset;
|
||||
y = height - safeVerticalMargin - textHeight + yOffset;
|
||||
break;
|
||||
case 'top-right':
|
||||
x =
|
||||
Math.max(
|
||||
safeHorizontalMargin,
|
||||
width - safeHorizontalMargin - textWidth
|
||||
) + xOffset;
|
||||
y = height - safeVerticalMargin - textHeight + yOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
x = Math.max(xOffset + 3, Math.min(xOffset + width - textWidth - 3, x));
|
||||
y = Math.max(yOffset + 3, Math.min(yOffset + height - textHeight - 3, y));
|
||||
|
||||
page.drawText(pageNumText, {
|
||||
x,
|
||||
y,
|
||||
font: helveticaFont,
|
||||
size: options.fontSize,
|
||||
color: rgb(options.color.r, options.color.g, options.color.b),
|
||||
});
|
||||
}
|
||||
|
||||
return new Uint8Array(await pdfDoc.save());
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { PDFDocument, PDFName } from 'pdf-lib';
|
||||
|
||||
export interface SanitizeOptions {
|
||||
flattenForms: boolean;
|
||||
removeMetadata: boolean;
|
||||
removeAnnotations: boolean;
|
||||
removeJavascript: boolean;
|
||||
removeEmbeddedFiles: boolean;
|
||||
removeLayers: boolean;
|
||||
removeLinks: boolean;
|
||||
removeStructureTree: boolean;
|
||||
removeMarkInfo: boolean;
|
||||
removeFonts: boolean;
|
||||
}
|
||||
|
||||
export const defaultSanitizeOptions: SanitizeOptions = {
|
||||
flattenForms: true,
|
||||
removeMetadata: true,
|
||||
removeAnnotations: true,
|
||||
removeJavascript: true,
|
||||
removeEmbeddedFiles: true,
|
||||
removeLayers: true,
|
||||
removeLinks: true,
|
||||
removeStructureTree: true,
|
||||
removeMarkInfo: true,
|
||||
removeFonts: false,
|
||||
};
|
||||
|
||||
function removeMetadataFromDoc(pdfDoc: PDFDocument) {
|
||||
const infoDict = (pdfDoc as any).getInfoDict();
|
||||
const allKeys = infoDict.keys();
|
||||
allKeys.forEach((key: any) => {
|
||||
infoDict.delete(key);
|
||||
});
|
||||
|
||||
pdfDoc.setTitle('');
|
||||
pdfDoc.setAuthor('');
|
||||
pdfDoc.setSubject('');
|
||||
pdfDoc.setKeywords([]);
|
||||
pdfDoc.setCreator('');
|
||||
pdfDoc.setProducer('');
|
||||
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
if (catalogDict.has(PDFName.of('Metadata'))) {
|
||||
catalogDict.delete(PDFName.of('Metadata'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove XMP metadata:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const context = pdfDoc.context;
|
||||
if ((context as any).trailerInfo) {
|
||||
delete (context as any).trailerInfo.ID;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove document IDs:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
if (catalogDict.has(PDFName.of('PieceInfo'))) {
|
||||
catalogDict.delete(PDFName.of('PieceInfo'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove PieceInfo:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove annotations from page:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenFormsInDoc(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
|
||||
if ((pdfDoc as any).javaScripts && (pdfDoc as any).javaScripts.length > 0) {
|
||||
(pdfDoc as any).javaScripts = [];
|
||||
}
|
||||
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
const namesRef = catalogDict.get(PDFName.of('Names'));
|
||||
if (namesRef) {
|
||||
try {
|
||||
const namesDict = pdfDoc.context.lookup(namesRef) as any;
|
||||
if (namesDict.has(PDFName.of('JavaScript'))) {
|
||||
namesDict.delete(PDFName.of('JavaScript'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Names/JavaScript:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('OpenAction'))) {
|
||||
catalogDict.delete(PDFName.of('OpenAction'));
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('AA'))) {
|
||||
catalogDict.delete(PDFName.of('AA'));
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
|
||||
if (pageDict.has(PDFName.of('AA'))) {
|
||||
pageDict.delete(PDFName.of('AA'));
|
||||
}
|
||||
|
||||
const annotRefs = pageDict.Annots()?.asArray() || [];
|
||||
for (const annotRef of annotRefs) {
|
||||
try {
|
||||
const annot = pdfDoc.context.lookup(annotRef) as any;
|
||||
|
||||
if (annot.has(PDFName.of('A'))) {
|
||||
const actionRef = annot.get(PDFName.of('A'));
|
||||
try {
|
||||
const actionDict = pdfDoc.context.lookup(actionRef) as any;
|
||||
const actionType = actionDict
|
||||
.get(PDFName.of('S'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (actionType === 'JavaScript') {
|
||||
annot.delete(PDFName.of('A'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not read action:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (annot.has(PDFName.of('AA'))) {
|
||||
annot.delete(PDFName.of('AA'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process annotation for JS:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove page actions:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const acroFormRef = catalogDict.get(PDFName.of('AcroForm'));
|
||||
if (acroFormRef) {
|
||||
const acroFormDict = pdfDoc.context.lookup(acroFormRef) as any;
|
||||
const fieldsRef = acroFormDict.get(PDFName.of('Fields'));
|
||||
|
||||
if (fieldsRef) {
|
||||
const fieldsArray = pdfDoc.context.lookup(fieldsRef) as any;
|
||||
const fields = fieldsArray.asArray();
|
||||
|
||||
for (const fieldRef of fields) {
|
||||
try {
|
||||
const field = pdfDoc.context.lookup(fieldRef) as any;
|
||||
|
||||
if (field.has(PDFName.of('A'))) {
|
||||
field.delete(PDFName.of('A'));
|
||||
}
|
||||
|
||||
if (field.has(PDFName.of('AA'))) {
|
||||
field.delete(PDFName.of('AA'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process field for JS:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process form fields for JS:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
const namesRef = catalogDict.get(PDFName.of('Names'));
|
||||
if (namesRef) {
|
||||
try {
|
||||
const namesDict = pdfDoc.context.lookup(namesRef) as any;
|
||||
if (namesDict.has(PDFName.of('EmbeddedFiles'))) {
|
||||
namesDict.delete(PDFName.of('EmbeddedFiles'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Names/EmbeddedFiles:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('EmbeddedFiles'))) {
|
||||
catalogDict.delete(PDFName.of('EmbeddedFiles'));
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const annotRefs = page.node.Annots()?.asArray() || [];
|
||||
const annotsToKeep = [];
|
||||
|
||||
for (const ref of annotRefs) {
|
||||
try {
|
||||
const annot = pdfDoc.context.lookup(ref) as any;
|
||||
const subtype = annot
|
||||
.get(PDFName.of('Subtype'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (subtype !== 'FileAttachment') {
|
||||
annotsToKeep.push(ref);
|
||||
}
|
||||
} catch (e) {
|
||||
annotsToKeep.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (annotsToKeep.length !== annotRefs.length) {
|
||||
if (annotsToKeep.length > 0) {
|
||||
const newAnnotsArray = pdfDoc.context.obj(annotsToKeep);
|
||||
page.node.set(PDFName.of('Annots'), newAnnotsArray);
|
||||
} else {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
}
|
||||
}
|
||||
} catch (pageError: any) {
|
||||
console.warn(
|
||||
`Could not process page for attachments: ${pageError.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(pdfDoc as any).embeddedFiles &&
|
||||
(pdfDoc as any).embeddedFiles.length > 0
|
||||
) {
|
||||
(pdfDoc as any).embeddedFiles = [];
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('Collection'))) {
|
||||
catalogDict.delete(PDFName.of('Collection'));
|
||||
}
|
||||
}
|
||||
|
||||
function removeLayersFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
if (catalogDict.has(PDFName.of('OCProperties'))) {
|
||||
catalogDict.delete(PDFName.of('OCProperties'));
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
|
||||
if (pageDict.has(PDFName.of('OCProperties'))) {
|
||||
pageDict.delete(PDFName.of('OCProperties'));
|
||||
}
|
||||
|
||||
const resourcesRef = pageDict.get(PDFName.of('Resources'));
|
||||
if (resourcesRef) {
|
||||
try {
|
||||
const resourcesDict = pdfDoc.context.lookup(resourcesRef) as any;
|
||||
if (resourcesDict.has(PDFName.of('Properties'))) {
|
||||
resourcesDict.delete(PDFName.of('Properties'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Resources:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove page layers:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeLinksFromDoc(pdfDoc: PDFDocument) {
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
|
||||
try {
|
||||
const page = pages[pageIndex];
|
||||
const pageDict = page.node;
|
||||
|
||||
const annotsRef = pageDict.get(PDFName.of('Annots'));
|
||||
if (!annotsRef) continue;
|
||||
|
||||
const annotsArray = pdfDoc.context.lookup(annotsRef) as any;
|
||||
const annotRefs = annotsArray.asArray();
|
||||
|
||||
if (annotRefs.length === 0) continue;
|
||||
|
||||
const annotsToKeep = [];
|
||||
let linksRemoved = 0;
|
||||
|
||||
for (const ref of annotRefs) {
|
||||
try {
|
||||
const annot = pdfDoc.context.lookup(ref) as any;
|
||||
const subtype = annot
|
||||
.get(PDFName.of('Subtype'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
let isLink = false;
|
||||
|
||||
if (subtype === 'Link') {
|
||||
isLink = true;
|
||||
linksRemoved++;
|
||||
} else {
|
||||
const actionRef = annot.get(PDFName.of('A'));
|
||||
if (actionRef) {
|
||||
try {
|
||||
const actionDict = pdfDoc.context.lookup(actionRef) as any;
|
||||
const actionType = actionDict
|
||||
.get(PDFName.of('S'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (
|
||||
actionType === 'URI' ||
|
||||
actionType === 'Launch' ||
|
||||
actionType === 'GoTo' ||
|
||||
actionType === 'GoToR'
|
||||
) {
|
||||
isLink = true;
|
||||
linksRemoved++;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not read action:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const dest = annot.get(PDFName.of('Dest'));
|
||||
if (dest && !isLink) {
|
||||
isLink = true;
|
||||
linksRemoved++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLink) {
|
||||
annotsToKeep.push(ref);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not process annotation:', e.message);
|
||||
annotsToKeep.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (linksRemoved > 0) {
|
||||
if (annotsToKeep.length > 0) {
|
||||
const newAnnotsArray = pdfDoc.context.obj(annotsToKeep);
|
||||
pageDict.set(PDFName.of('Annots'), newAnnotsArray);
|
||||
} else {
|
||||
pageDict.delete(PDFName.of('Annots'));
|
||||
}
|
||||
}
|
||||
} catch (pageError: any) {
|
||||
console.warn(
|
||||
`Could not process page ${pageIndex + 1} for links: ${pageError.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
const namesRef = catalogDict.get(PDFName.of('Names'));
|
||||
if (namesRef) {
|
||||
try {
|
||||
const namesDict = pdfDoc.context.lookup(namesRef) as any;
|
||||
if (namesDict.has(PDFName.of('Dests'))) {
|
||||
namesDict.delete(PDFName.of('Dests'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Names/Dests:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('Dests'))) {
|
||||
catalogDict.delete(PDFName.of('Dests'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove named destinations:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function removeStructureTreeFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
if (catalogDict.has(PDFName.of('StructTreeRoot'))) {
|
||||
catalogDict.delete(PDFName.of('StructTreeRoot'));
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
if (pageDict.has(PDFName.of('StructParents'))) {
|
||||
pageDict.delete(PDFName.of('StructParents'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not remove page StructParents:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('ParentTree'))) {
|
||||
catalogDict.delete(PDFName.of('ParentTree'));
|
||||
}
|
||||
}
|
||||
|
||||
function removeMarkInfoFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
|
||||
if (catalogDict.has(PDFName.of('MarkInfo'))) {
|
||||
catalogDict.delete(PDFName.of('MarkInfo'));
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('Marked'))) {
|
||||
catalogDict.delete(PDFName.of('Marked'));
|
||||
}
|
||||
}
|
||||
|
||||
function removeFontsFromDoc(pdfDoc: PDFDocument) {
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
|
||||
try {
|
||||
const page = pages[pageIndex];
|
||||
const pageDict = page.node;
|
||||
const resourcesRef = pageDict.get(PDFName.of('Resources'));
|
||||
|
||||
if (resourcesRef) {
|
||||
try {
|
||||
const resourcesDict = pdfDoc.context.lookup(resourcesRef) as any;
|
||||
|
||||
if (resourcesDict.has(PDFName.of('Font'))) {
|
||||
const fontRef = resourcesDict.get(PDFName.of('Font'));
|
||||
|
||||
try {
|
||||
const fontDict = pdfDoc.context.lookup(fontRef) as any;
|
||||
const fontKeys = fontDict.keys();
|
||||
|
||||
for (const fontKey of fontKeys) {
|
||||
try {
|
||||
const specificFontRef = fontDict.get(fontKey);
|
||||
const specificFont = pdfDoc.context.lookup(
|
||||
specificFontRef
|
||||
) as any;
|
||||
|
||||
if (specificFont.has(PDFName.of('FontDescriptor'))) {
|
||||
const descriptorRef = specificFont.get(
|
||||
PDFName.of('FontDescriptor')
|
||||
);
|
||||
const descriptor = pdfDoc.context.lookup(
|
||||
descriptorRef
|
||||
) as any;
|
||||
|
||||
const fontFileKeys = ['FontFile', 'FontFile2', 'FontFile3'];
|
||||
for (const key of fontFileKeys) {
|
||||
if (descriptor.has(PDFName.of(key))) {
|
||||
descriptor.delete(PDFName.of(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not process font ${fontKey}:`, e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access font dictionary:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Could not access Resources for fonts:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(
|
||||
`Could not remove fonts from page ${pageIndex + 1}:`,
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((pdfDoc as any).fonts && (pdfDoc as any).fonts.length > 0) {
|
||||
(pdfDoc as any).fonts = [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function sanitizePdf(
|
||||
pdfBytes: Uint8Array,
|
||||
options: SanitizeOptions
|
||||
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array }> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
if (options.flattenForms) {
|
||||
try {
|
||||
flattenFormsInDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not flatten forms: ${e.message}`);
|
||||
try {
|
||||
const catalogDict = (pdfDoc.catalog as any).dict;
|
||||
if (catalogDict.has(PDFName.of('AcroForm'))) {
|
||||
catalogDict.delete(PDFName.of('AcroForm'));
|
||||
}
|
||||
} catch (removeError: any) {
|
||||
console.warn('Could not remove AcroForm:', removeError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.removeMetadata) {
|
||||
removeMetadataFromDoc(pdfDoc);
|
||||
}
|
||||
|
||||
if (options.removeAnnotations) {
|
||||
removeAnnotationsFromDoc(pdfDoc);
|
||||
}
|
||||
|
||||
if (options.removeJavascript) {
|
||||
try {
|
||||
removeJavascriptFromDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove JavaScript: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.removeEmbeddedFiles) {
|
||||
try {
|
||||
removeEmbeddedFilesFromDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove embedded files: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.removeLayers) {
|
||||
try {
|
||||
removeLayersFromDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove layers: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.removeLinks) {
|
||||
try {
|
||||
removeLinksFromDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove links: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.removeStructureTree) {
|
||||
try {
|
||||
removeStructureTreeFromDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove structure tree: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.removeMarkInfo) {
|
||||
try {
|
||||
removeMarkInfoFromDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove MarkInfo: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.removeFonts) {
|
||||
try {
|
||||
removeFontsFromDoc(pdfDoc);
|
||||
} catch (e: any) {
|
||||
console.warn(`Could not remove fonts: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const savedBytes = await pdfDoc.save();
|
||||
return { pdfDoc, bytes: new Uint8Array(savedBytes) };
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { NodeEditor } from 'rete';
|
||||
import { AreaPlugin, AreaExtensions } from 'rete-area-plugin';
|
||||
import {
|
||||
ConnectionPlugin,
|
||||
Presets as ConnectionPresets,
|
||||
} from 'rete-connection-plugin';
|
||||
import { LitPlugin, Presets as LitPresets } from '@retejs/lit-plugin';
|
||||
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
||||
import { DataflowEngine } from 'rete-engine';
|
||||
import type { DataflowEngineScheme } from 'rete-engine';
|
||||
import { html } from 'lit';
|
||||
import type { BaseWorkflowNode } from './nodes/base-node';
|
||||
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
|
||||
import phosphorCSS from '@phosphor-icons/web/regular?inline';
|
||||
|
||||
// Shared stylesheet for Phosphor icons (font-face already loaded globally, strip it)
|
||||
const phosphorSheet = new CSSStyleSheet();
|
||||
phosphorSheet.replaceSync(phosphorCSS.replace(/@font-face[^}]*\}/g, ''));
|
||||
|
||||
type AreaExtra = LitArea2D<ClassicScheme>;
|
||||
|
||||
export interface WorkflowEditor {
|
||||
editor: NodeEditor<ClassicScheme>;
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>;
|
||||
engine: DataflowEngine<DataflowEngineScheme>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
Input: '#60a5fa',
|
||||
'Edit & Annotate': '#a5b4fc',
|
||||
'Organize & Manage': '#c4b5fd',
|
||||
'Optimize & Repair': '#fcd34d',
|
||||
'Secure PDF': '#fda4af',
|
||||
Output: '#5eead4',
|
||||
};
|
||||
|
||||
function getStatusInfo(status: string, connected: boolean) {
|
||||
if (status === 'running')
|
||||
return { color: '#eab308', label: 'Running...', animate: true };
|
||||
if (status === 'completed')
|
||||
return { color: '#22c55e', label: 'Complete', animate: false };
|
||||
if (status === 'error')
|
||||
return { color: '#ef4444', label: 'Failed', animate: false };
|
||||
return {
|
||||
color: connected ? '#22c55e' : '#6b7280',
|
||||
label: connected ? 'Connected' : 'Not connected',
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateNodeDisplay(
|
||||
nodeId: string,
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||
) {
|
||||
const view = area.nodeViews.get(nodeId);
|
||||
if (!view) return;
|
||||
const el = view.element;
|
||||
const node = editor.getNode(nodeId) as BaseWorkflowNode;
|
||||
if (!node) return;
|
||||
|
||||
const conns = editor.getConnections();
|
||||
const connected = conns.some(
|
||||
(c) => c.target === nodeId || c.source === nodeId
|
||||
);
|
||||
const status = node.execStatus || 'idle';
|
||||
const st = getStatusInfo(status, connected);
|
||||
|
||||
const bar = el.querySelector<HTMLElement>('[data-wf="bar"]');
|
||||
const dot = el.querySelector<HTMLElement>('[data-wf="dot"]');
|
||||
const label = el.querySelector<HTMLElement>('[data-wf="label"]');
|
||||
|
||||
if (bar) {
|
||||
bar.className = st.animate ? 'wf-bar-slide' : '';
|
||||
bar.style.background = st.animate
|
||||
? `linear-gradient(90deg, #1f2937 0%, ${st.color} 50%, #1f2937 100%)`
|
||||
: st.color;
|
||||
bar.style.opacity =
|
||||
status === 'idle' && !connected
|
||||
? '0.25'
|
||||
: status === 'idle'
|
||||
? '0.5'
|
||||
: '1';
|
||||
bar.style.backgroundSize = st.animate ? '200% 100%' : '';
|
||||
}
|
||||
|
||||
if (dot) {
|
||||
dot.className = st.animate ? 'wf-dot-pulse' : '';
|
||||
dot.style.background = st.color;
|
||||
dot.style.boxShadow = 'none';
|
||||
}
|
||||
|
||||
if (label) {
|
||||
label.style.color = st.color;
|
||||
label.textContent = st.label;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkflowEditor(
|
||||
container: HTMLElement
|
||||
): Promise<WorkflowEditor> {
|
||||
const editor = new NodeEditor<ClassicScheme>();
|
||||
const area = new AreaPlugin<ClassicScheme, AreaExtra>(container);
|
||||
const connection = new ConnectionPlugin<ClassicScheme, AreaExtra>();
|
||||
const litPlugin = new LitPlugin<ClassicScheme, AreaExtra>();
|
||||
const engine = new DataflowEngine<DataflowEngineScheme>();
|
||||
|
||||
litPlugin.addPreset(
|
||||
LitPresets.classic.setup({
|
||||
customize: {
|
||||
node(data) {
|
||||
return ({ emit }: { emit: (data: unknown) => void }) => {
|
||||
const node = data.payload as BaseWorkflowNode;
|
||||
const inputs = Object.entries(node.inputs || {});
|
||||
const outputs = Object.entries(node.outputs || {});
|
||||
const color = categoryColors[node.category] || '#6b7280';
|
||||
|
||||
return html`
|
||||
<div
|
||||
style="
|
||||
position: relative; display: flex; flex-direction: column;
|
||||
align-items: center; width: 280px;
|
||||
"
|
||||
>
|
||||
${inputs.length > 0
|
||||
? html`
|
||||
<div
|
||||
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
|
||||
>
|
||||
${inputs.map(([key, input]) =>
|
||||
input
|
||||
? html`
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: center;"
|
||||
>
|
||||
<rete-ref
|
||||
.data=${{
|
||||
type: 'socket',
|
||||
side: 'input',
|
||||
key,
|
||||
nodeId: node.id,
|
||||
payload: input.socket,
|
||||
}}
|
||||
.emit=${emit}
|
||||
></rete-ref>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div
|
||||
style="
|
||||
background: #1f2937; border: 1px solid #374151;
|
||||
border-radius: 12px; width: 100%; overflow: hidden;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
|
||||
>
|
||||
<div
|
||||
data-wf="bar"
|
||||
style="
|
||||
height: 100%; width: 100%;
|
||||
background: #6b7280; opacity: 0.25;
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
|
||||
>
|
||||
<span
|
||||
data-wf="dot"
|
||||
style="
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
|
||||
"
|
||||
></span>
|
||||
<span
|
||||
data-wf="label"
|
||||
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
|
||||
>Not connected</span
|
||||
>
|
||||
<span
|
||||
data-wf-delete="${node.id}"
|
||||
style="
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; border-radius: 4px;
|
||||
color: #6b7280; transition: all 0.15s;
|
||||
"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style="height: 1px; background: #374151; margin: 0 14px;"
|
||||
></div>
|
||||
<div
|
||||
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
|
||||
>
|
||||
<i
|
||||
class="ph ${node.icon}"
|
||||
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
|
||||
></i>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div
|
||||
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
|
||||
>
|
||||
${node.label}
|
||||
</div>
|
||||
<div
|
||||
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
|
||||
>
|
||||
${node.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${outputs.length > 0
|
||||
? html`
|
||||
<div
|
||||
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
|
||||
>
|
||||
${outputs.map(([key, output]) =>
|
||||
output
|
||||
? html`
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: center;"
|
||||
>
|
||||
<rete-ref
|
||||
.data=${{
|
||||
type: 'socket',
|
||||
side: 'output',
|
||||
key,
|
||||
nodeId: node.id,
|
||||
payload: output.socket,
|
||||
}}
|
||||
.emit=${emit}
|
||||
></rete-ref>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
},
|
||||
socket() {
|
||||
return () => {
|
||||
return html`<div
|
||||
style="
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: #6366f1; border: 2px solid #1f2937;
|
||||
box-shadow: 0 0 0 1px #6366f1; cursor: crosshair;
|
||||
"
|
||||
></div>`;
|
||||
};
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
connection.addPreset(ConnectionPresets.classic.setup());
|
||||
|
||||
// Override connection path to use vertical bezier curves (top-to-bottom flow)
|
||||
litPlugin.addPipe((context) => {
|
||||
if ((context as any).type === 'connectionpath') {
|
||||
const { points } = (context as any).data;
|
||||
const [start, end] = points as [
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
];
|
||||
const curvature = 0.3;
|
||||
const horizontal = Math.abs(start.x - end.x);
|
||||
const dy =
|
||||
Math.max(horizontal / 2, Math.abs(end.y - start.y)) * curvature;
|
||||
const path = `M ${start.x} ${start.y} C ${start.x} ${start.y + dy} ${end.x} ${end.y - dy} ${end.x} ${end.y}`;
|
||||
return {
|
||||
...context,
|
||||
data: { ...(context as any).data, path },
|
||||
} as typeof context;
|
||||
}
|
||||
return context;
|
||||
});
|
||||
|
||||
editor.use(area);
|
||||
area.use(connection);
|
||||
area.use(litPlugin);
|
||||
(editor as NodeEditor<DataflowEngineScheme>).use(engine);
|
||||
|
||||
AreaExtensions.selectableNodes(area, AreaExtensions.selector(), {
|
||||
accumulating: AreaExtensions.accumulateOnCtrl(),
|
||||
});
|
||||
|
||||
AreaExtensions.simpleNodesOrder(area);
|
||||
|
||||
// Inject Phosphor icon styles into Shadow DOM roots created by the Lit plugin
|
||||
let phosphorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const injectPhosphor = () => {
|
||||
if (phosphorTimer) return;
|
||||
phosphorTimer = setTimeout(() => {
|
||||
phosphorTimer = null;
|
||||
for (const el of container.querySelectorAll('*')) {
|
||||
const sr = (el as HTMLElement).shadowRoot;
|
||||
if (sr && !sr.adoptedStyleSheets.includes(phosphorSheet)) {
|
||||
sr.adoptedStyleSheets = [...sr.adoptedStyleSheets, phosphorSheet];
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
const observer = new MutationObserver(injectPhosphor);
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
|
||||
const onPointerDown = (e: Event) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(
|
||||
'[data-wf-delete]'
|
||||
);
|
||||
if (!target) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nodeId = target.getAttribute('data-wf-delete');
|
||||
if (nodeId) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('wf-delete-node', { detail: { nodeId } })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseEnter = (e: Event) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(
|
||||
'[data-wf-delete]'
|
||||
);
|
||||
if (!target) return;
|
||||
target.style.color = '#f87171';
|
||||
target.style.background = 'rgba(248,113,113,0.1)';
|
||||
};
|
||||
|
||||
const onMouseLeave = (e: Event) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(
|
||||
'[data-wf-delete]'
|
||||
);
|
||||
if (!target) return;
|
||||
target.style.color = '#6b7280';
|
||||
target.style.background = 'transparent';
|
||||
};
|
||||
|
||||
container.addEventListener('pointerdown', onPointerDown, true);
|
||||
container.addEventListener('mouseenter', onMouseEnter, true);
|
||||
container.addEventListener('mouseleave', onMouseLeave, true);
|
||||
|
||||
return {
|
||||
editor,
|
||||
area,
|
||||
engine,
|
||||
destroy: () => {
|
||||
observer.disconnect();
|
||||
if (phosphorTimer) {
|
||||
clearTimeout(phosphorTimer);
|
||||
phosphorTimer = null;
|
||||
}
|
||||
container.removeEventListener('pointerdown', onPointerDown, true);
|
||||
container.removeEventListener('mouseenter', onMouseEnter, true);
|
||||
container.removeEventListener('mouseleave', onMouseLeave, true);
|
||||
area.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
||||
import { NodeEditor } from 'rete';
|
||||
import { AreaPlugin } from 'rete-area-plugin';
|
||||
import { DataflowEngine } from 'rete-engine';
|
||||
import type { DataflowEngineScheme } from 'rete-engine';
|
||||
import type { BaseWorkflowNode } from './nodes/base-node';
|
||||
import { WorkflowError } from './types';
|
||||
import type { ExecutionProgress } from './types';
|
||||
import { updateNodeDisplay } from './editor';
|
||||
|
||||
type AreaExtra = LitArea2D<ClassicScheme>;
|
||||
|
||||
function getUpstreamNodes(
|
||||
nodeId: string,
|
||||
editor: NodeEditor<ClassicScheme>
|
||||
): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const queue = [nodeId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
const incoming = editor
|
||||
.getConnections()
|
||||
.filter((c) => c.target === current);
|
||||
for (const conn of incoming) {
|
||||
queue.push(conn.source);
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
|
||||
function topologicalSort(
|
||||
nodeIds: Set<string>,
|
||||
editor: NodeEditor<ClassicScheme>
|
||||
): string[] {
|
||||
const inDegree = new Map<string, number>();
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const id of nodeIds) {
|
||||
inDegree.set(id, 0);
|
||||
adj.set(id, []);
|
||||
}
|
||||
for (const conn of editor.getConnections()) {
|
||||
if (nodeIds.has(conn.source) && nodeIds.has(conn.target)) {
|
||||
adj.get(conn.source)!.push(conn.target);
|
||||
inDegree.set(conn.target, (inDegree.get(conn.target) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const queue: string[] = [];
|
||||
for (const [id, deg] of inDegree) {
|
||||
if (deg === 0) queue.push(id);
|
||||
}
|
||||
const sorted: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
sorted.push(current);
|
||||
for (const next of adj.get(current) || []) {
|
||||
const newDeg = (inDegree.get(next) ?? 0) - 1;
|
||||
inDegree.set(next, newDeg);
|
||||
if (newDeg === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
if (sorted.length < nodeIds.size) {
|
||||
throw new WorkflowError(
|
||||
'Circular dependency detected in workflow. Please remove any loops between nodes.'
|
||||
);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
export async function executeWorkflow(
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
engine: DataflowEngine<DataflowEngineScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>,
|
||||
onProgress: (progress: ExecutionProgress) => void
|
||||
): Promise<void> {
|
||||
const nodes = editor.getNodes() as BaseWorkflowNode[];
|
||||
const connections = editor.getConnections();
|
||||
|
||||
const nodesWithOutputConnections = new Set(connections.map((c) => c.source));
|
||||
const terminalNodes = nodes.filter(
|
||||
(n) => !nodesWithOutputConnections.has(n.id)
|
||||
);
|
||||
|
||||
if (terminalNodes.length === 0) {
|
||||
throw new Error(
|
||||
'No output nodes found. Add a Download node to complete your workflow.'
|
||||
);
|
||||
}
|
||||
|
||||
const pipelineNodes = new Set<string>();
|
||||
for (const terminal of terminalNodes) {
|
||||
const upstream = getUpstreamNodes(terminal.id, editor);
|
||||
for (const id of upstream) pipelineNodes.add(id);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
node.execStatus = 'idle';
|
||||
updateNodeDisplay(node.id, editor, area);
|
||||
}
|
||||
await tick();
|
||||
|
||||
const sorted = topologicalSort(pipelineNodes, editor);
|
||||
|
||||
engine.reset();
|
||||
|
||||
for (const nodeId of sorted) {
|
||||
const node = editor.getNode(nodeId) as BaseWorkflowNode;
|
||||
if (!node) continue;
|
||||
|
||||
node.execStatus = 'running';
|
||||
updateNodeDisplay(node.id, editor, area);
|
||||
onProgress({
|
||||
nodeId: node.id,
|
||||
nodeName: node.label,
|
||||
status: 'running',
|
||||
message: `Processing ${node.label}...`,
|
||||
});
|
||||
await tick();
|
||||
|
||||
try {
|
||||
await engine.fetch(nodeId);
|
||||
|
||||
node.execStatus = 'completed';
|
||||
updateNodeDisplay(node.id, editor, area);
|
||||
onProgress({
|
||||
nodeId: node.id,
|
||||
nodeName: node.label,
|
||||
status: 'completed',
|
||||
});
|
||||
await tick();
|
||||
} catch (error) {
|
||||
node.execStatus = 'error';
|
||||
updateNodeDisplay(node.id, editor, area);
|
||||
|
||||
for (const remainingId of sorted) {
|
||||
const remaining = editor.getNode(remainingId) as BaseWorkflowNode;
|
||||
if (remaining && remaining.execStatus === 'running') {
|
||||
remaining.execStatus = 'idle';
|
||||
updateNodeDisplay(remaining.id, editor, area);
|
||||
}
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const wrapped =
|
||||
error instanceof WorkflowError
|
||||
? error
|
||||
: new WorkflowError(message, node.label);
|
||||
|
||||
onProgress({
|
||||
nodeId: node.id,
|
||||
nodeName: node.label,
|
||||
status: 'error',
|
||||
message: wrapped.message,
|
||||
});
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class AddBlankPageNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-file-plus';
|
||||
readonly description = 'Insert blank pages';
|
||||
|
||||
constructor() {
|
||||
super('Add Blank Page');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'blankPosition',
|
||||
new ClassicPreset.InputControl('text', { initial: 'end' })
|
||||
);
|
||||
this.addControl(
|
||||
'afterPage',
|
||||
new ClassicPreset.InputControl('number', { initial: 1 })
|
||||
);
|
||||
this.addControl(
|
||||
'count',
|
||||
new ClassicPreset.InputControl('number', { initial: 1 })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Add Blank Page');
|
||||
const posCtrl = this.controls['blankPosition'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const position = posCtrl?.value || 'end';
|
||||
const afterPageCtrl = this.controls['afterPage'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const afterPage = afterPageCtrl?.value ?? 1;
|
||||
const countCtrl = this.controls['count'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const count = Math.max(1, Math.min(100, countCtrl?.value ?? 1));
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfDoc = await PDFDocument.load(input.bytes);
|
||||
const firstPage = pdfDoc.getPages()[0];
|
||||
const { width, height } = firstPage
|
||||
? firstPage.getSize()
|
||||
: { width: 595.28, height: 841.89 };
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (position === 'start') {
|
||||
pdfDoc.insertPage(0, [width, height]);
|
||||
} else if (position === 'after') {
|
||||
const insertAt =
|
||||
Math.min(Math.max(1, afterPage), pdfDoc.getPageCount()) + i;
|
||||
pdfDoc.insertPage(insertAt, [width, height]);
|
||||
} else {
|
||||
pdfDoc.addPage([width, height]);
|
||||
}
|
||||
}
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_blank.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { applyColorAdjustments } from '../../utils/image-effects';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { AdjustColorsSettings } from '../../types/adjust-colors-type';
|
||||
|
||||
export class AdjustColorsNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-sliders-horizontal';
|
||||
readonly description = 'Adjust brightness, contrast, and colors';
|
||||
|
||||
constructor() {
|
||||
super('Adjust Colors');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Adjusted PDF'));
|
||||
this.addControl(
|
||||
'brightness',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'contrast',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'saturation',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'hueShift',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'temperature',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'tint',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'gamma',
|
||||
new ClassicPreset.InputControl('number', { initial: 1.0 })
|
||||
);
|
||||
this.addControl(
|
||||
'sepia',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Adjust Colors');
|
||||
|
||||
const getNum = (key: string, fallback: number) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
return ctrl?.value ?? fallback;
|
||||
};
|
||||
|
||||
const settings: AdjustColorsSettings = {
|
||||
brightness: getNum('brightness', 0),
|
||||
contrast: getNum('contrast', 0),
|
||||
saturation: getNum('saturation', 0),
|
||||
hueShift: getNum('hueShift', 0),
|
||||
temperature: getNum('temperature', 0),
|
||||
tint: getNum('tint', 0),
|
||||
gamma: getNum('gamma', 1.0),
|
||||
sepia: getNum('sepia', 0),
|
||||
};
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: input.bytes })
|
||||
.promise;
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
|
||||
const renderCanvas = document.createElement('canvas');
|
||||
renderCanvas.width = viewport.width;
|
||||
renderCanvas.height = viewport.height;
|
||||
const renderCtx = renderCanvas.getContext('2d');
|
||||
if (!renderCtx)
|
||||
throw new Error(`Failed to get canvas context for page ${i}`);
|
||||
await page.render({
|
||||
canvasContext: renderCtx,
|
||||
viewport,
|
||||
canvas: renderCanvas,
|
||||
}).promise;
|
||||
|
||||
const baseData = renderCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
renderCanvas.width,
|
||||
renderCanvas.height
|
||||
);
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
applyColorAdjustments(baseData, outputCanvas, settings);
|
||||
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) =>
|
||||
outputCanvas.toBlob(resolve, 'image/png')
|
||||
);
|
||||
|
||||
if (!pngBlob) throw new Error(`Failed to render page ${i} to image`);
|
||||
|
||||
const pngBytes = await pngBlob.arrayBuffer();
|
||||
const pngImage = await newPdfDoc.embedPng(pngBytes);
|
||||
const origViewport = page.getViewport({ scale: 1.0 });
|
||||
const newPage = newPdfDoc.addPage([
|
||||
origViewport.width,
|
||||
origViewport.height,
|
||||
]);
|
||||
newPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: origViewport.width,
|
||||
height: origViewport.height,
|
||||
});
|
||||
}
|
||||
|
||||
if (newPdfDoc.getPageCount() === 0)
|
||||
throw new Error('No pages were processed');
|
||||
const pdfBytes = await newPdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newPdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_adjusted.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
|
||||
export class BackgroundColorNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-palette';
|
||||
readonly description = 'Change background color';
|
||||
|
||||
constructor() {
|
||||
super('Background Color');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'color',
|
||||
new ClassicPreset.InputControl('text', { initial: '#ffffff' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Background Color');
|
||||
|
||||
const colorCtrl = this.controls['color'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const hex = colorCtrl?.value || '#ffffff';
|
||||
const c = hexToRgb(hex);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const newDoc = await PDFDocument.create();
|
||||
|
||||
for (let i = 0; i < srcDoc.getPageCount(); i++) {
|
||||
const [originalPage] = await newDoc.copyPages(srcDoc, [i]);
|
||||
const { width, height } = originalPage.getSize();
|
||||
const newPage = newDoc.addPage([width, height]);
|
||||
newPage.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
color: rgb(c.r, c.g, c.b),
|
||||
});
|
||||
const embedded = await newDoc.embedPage(originalPage);
|
||||
newPage.drawPage(embedded, { x: 0, y: 0, width, height });
|
||||
}
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_bg.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import type { NodeCategory, NodeMeta, SocketData } from '../types';
|
||||
|
||||
export abstract class BaseWorkflowNode extends ClassicPreset.Node {
|
||||
abstract readonly category: NodeCategory;
|
||||
abstract readonly icon: string;
|
||||
abstract readonly description: string;
|
||||
|
||||
width = 280;
|
||||
height = 140;
|
||||
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
|
||||
|
||||
constructor(label: string) {
|
||||
super(label);
|
||||
}
|
||||
|
||||
abstract data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>>;
|
||||
|
||||
getMeta(): NodeMeta {
|
||||
return {
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
category: this.category,
|
||||
icon: this.icon,
|
||||
description: this.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument, PageSizes } from 'pdf-lib';
|
||||
|
||||
const paperSizeLookup: Record<string, [number, number]> = {
|
||||
Letter: PageSizes.Letter,
|
||||
A4: PageSizes.A4,
|
||||
A3: PageSizes.A3,
|
||||
Tabloid: PageSizes.Tabloid,
|
||||
Legal: PageSizes.Legal,
|
||||
};
|
||||
|
||||
export class BookletNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-book-open';
|
||||
readonly description = 'Arrange pages for booklet printing';
|
||||
|
||||
constructor() {
|
||||
super('Booklet');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Booklet PDF'));
|
||||
this.addControl(
|
||||
'gridMode',
|
||||
new ClassicPreset.InputControl('text', { initial: '1x2' })
|
||||
);
|
||||
this.addControl(
|
||||
'paperSize',
|
||||
new ClassicPreset.InputControl('text', { initial: 'Letter' })
|
||||
);
|
||||
this.addControl(
|
||||
'orientation',
|
||||
new ClassicPreset.InputControl('text', { initial: 'auto' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Booklet');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
|
||||
const gridMode = getText('gridMode', '1x2');
|
||||
const paperSizeKey = getText('paperSize', 'Letter');
|
||||
const orientationVal = getText('orientation', 'auto');
|
||||
|
||||
let rows: number, cols: number;
|
||||
switch (gridMode) {
|
||||
case '2x2':
|
||||
rows = 2;
|
||||
cols = 2;
|
||||
break;
|
||||
case '2x4':
|
||||
rows = 2;
|
||||
cols = 4;
|
||||
break;
|
||||
case '4x4':
|
||||
rows = 4;
|
||||
cols = 4;
|
||||
break;
|
||||
default:
|
||||
rows = 1;
|
||||
cols = 2;
|
||||
break;
|
||||
}
|
||||
|
||||
const isBookletMode = rows === 1 && cols === 2;
|
||||
const pageDims = paperSizeLookup[paperSizeKey] || PageSizes.Letter;
|
||||
const orientation =
|
||||
orientationVal === 'portrait'
|
||||
? 'portrait'
|
||||
: orientationVal === 'landscape'
|
||||
? 'landscape'
|
||||
: isBookletMode
|
||||
? 'landscape'
|
||||
: 'portrait';
|
||||
|
||||
const sheetWidth = orientation === 'landscape' ? pageDims[1] : pageDims[0];
|
||||
const sheetHeight = orientation === 'landscape' ? pageDims[0] : pageDims[1];
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const sourceDoc = await PDFDocument.load(input.bytes);
|
||||
const totalPages = sourceDoc.getPageCount();
|
||||
const pagesPerSheet = rows * cols;
|
||||
const outputDoc = await PDFDocument.create();
|
||||
|
||||
let numSheets: number;
|
||||
let totalRounded: number;
|
||||
if (isBookletMode) {
|
||||
totalRounded = Math.ceil(totalPages / 4) * 4;
|
||||
numSheets = Math.ceil(totalPages / 4) * 2;
|
||||
} else {
|
||||
totalRounded = totalPages;
|
||||
numSheets = Math.ceil(totalPages / pagesPerSheet);
|
||||
}
|
||||
|
||||
const cellWidth = sheetWidth / cols;
|
||||
const cellHeight = sheetHeight / rows;
|
||||
const padding = 10;
|
||||
|
||||
for (let sheetIndex = 0; sheetIndex < numSheets; sheetIndex++) {
|
||||
const outputPage = outputDoc.addPage([sheetWidth, sheetHeight]);
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const slotIndex = r * cols + c;
|
||||
let pageNumber: number;
|
||||
|
||||
if (isBookletMode) {
|
||||
const physicalSheet = Math.floor(sheetIndex / 2);
|
||||
const isFrontSide = sheetIndex % 2 === 0;
|
||||
if (isFrontSide) {
|
||||
pageNumber =
|
||||
c === 0
|
||||
? totalRounded - 2 * physicalSheet
|
||||
: 2 * physicalSheet + 1;
|
||||
} else {
|
||||
pageNumber =
|
||||
c === 0
|
||||
? 2 * physicalSheet + 2
|
||||
: totalRounded - 2 * physicalSheet - 1;
|
||||
}
|
||||
} else {
|
||||
pageNumber = sheetIndex * pagesPerSheet + slotIndex + 1;
|
||||
}
|
||||
|
||||
if (pageNumber >= 1 && pageNumber <= totalPages) {
|
||||
const [embeddedPage] = await outputDoc.embedPdf(sourceDoc, [
|
||||
pageNumber - 1,
|
||||
]);
|
||||
const { width: srcW, height: srcH } = embeddedPage;
|
||||
const availableWidth = cellWidth - padding * 2;
|
||||
const availableHeight = cellHeight - padding * 2;
|
||||
const scale = Math.min(
|
||||
availableWidth / srcW,
|
||||
availableHeight / srcH
|
||||
);
|
||||
const scaledWidth = srcW * scale;
|
||||
const scaledHeight = srcH * scale;
|
||||
const x =
|
||||
c * cellWidth + padding + (availableWidth - scaledWidth) / 2;
|
||||
const y =
|
||||
sheetHeight -
|
||||
(r + 1) * cellHeight +
|
||||
padding +
|
||||
(availableHeight - scaledHeight) / 2;
|
||||
outputPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
y,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = new Uint8Array(await outputDoc.save());
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: outputDoc,
|
||||
bytes: pdfBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_booklet.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class CbzToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-book-open';
|
||||
readonly description = 'Upload comic book archives and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('CBZ Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith('.cbz') || name.endsWith('.cbr')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} comic archives`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No comic archives uploaded in CBZ Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const blob = await pymupdf.convertToPdf(file, { filetype: 'cbz' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.(cbz|cbr)$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
|
||||
export class CombineSinglePageNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-arrows-out-line-vertical';
|
||||
readonly description = 'Stitch all pages into one continuous page';
|
||||
|
||||
constructor() {
|
||||
super('Combine to Single Page');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Combined PDF'));
|
||||
this.addControl(
|
||||
'orientation',
|
||||
new ClassicPreset.InputControl('text', { initial: 'vertical' })
|
||||
);
|
||||
this.addControl(
|
||||
'spacing',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'backgroundColor',
|
||||
new ClassicPreset.InputControl('text', { initial: '#ffffff' })
|
||||
);
|
||||
this.addControl(
|
||||
'separator',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
this.addControl(
|
||||
'separatorThickness',
|
||||
new ClassicPreset.InputControl('number', { initial: 0.5 })
|
||||
);
|
||||
this.addControl(
|
||||
'separatorColor',
|
||||
new ClassicPreset.InputControl('text', { initial: '#000000' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Combine to Single Page');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
const getNum = (key: string, fallback: number) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
return ctrl?.value ?? fallback;
|
||||
};
|
||||
|
||||
const isVertical = getText('orientation', 'vertical') === 'vertical';
|
||||
const spacing = Math.max(0, getNum('spacing', 0));
|
||||
const bgC = hexToRgb(getText('backgroundColor', '#ffffff'));
|
||||
const bgColor = rgb(bgC.r, bgC.g, bgC.b);
|
||||
const addSeparator = getText('separator', 'false') === 'true';
|
||||
const sepThickness = getNum('separatorThickness', 0.5);
|
||||
const sepC = hexToRgb(getText('separatorColor', '#000000'));
|
||||
const sepColor = rgb(sepC.r, sepC.g, sepC.b);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: input.bytes })
|
||||
.promise;
|
||||
const newDoc = await PDFDocument.create();
|
||||
|
||||
const pageDims: { width: number; height: number }[] = [];
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const vp = page.getViewport({ scale: 1.0 });
|
||||
pageDims.push({ width: vp.width, height: vp.height });
|
||||
}
|
||||
|
||||
const maxWidth = Math.max(...pageDims.map((d) => d.width));
|
||||
const maxHeight = Math.max(...pageDims.map((d) => d.height));
|
||||
const totalSpacing = spacing * (pdfjsDoc.numPages - 1);
|
||||
|
||||
const finalWidth = isVertical
|
||||
? maxWidth
|
||||
: pageDims.reduce((sum, d) => sum + d.width, 0) + totalSpacing;
|
||||
const finalHeight = isVertical
|
||||
? pageDims.reduce((sum, d) => sum + d.height, 0) + totalSpacing
|
||||
: maxHeight;
|
||||
|
||||
const combinedPage = newDoc.addPage([finalWidth, finalHeight]);
|
||||
combinedPage.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
color: bgColor,
|
||||
});
|
||||
|
||||
let offset = isVertical ? finalHeight : 0;
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx)
|
||||
throw new Error(`Failed to get canvas context for page ${i}`);
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
);
|
||||
if (!pngBlob) throw new Error(`Failed to render page ${i} to image`);
|
||||
|
||||
const pngBytes = await pngBlob.arrayBuffer();
|
||||
const pngImage = await newDoc.embedPng(pngBytes);
|
||||
const dim = pageDims[i - 1];
|
||||
|
||||
if (isVertical) {
|
||||
offset -= dim.height;
|
||||
combinedPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: offset,
|
||||
width: dim.width,
|
||||
height: dim.height,
|
||||
});
|
||||
if (addSeparator && i < pdfjsDoc.numPages) {
|
||||
combinedPage.drawLine({
|
||||
start: { x: 0, y: offset - spacing / 2 },
|
||||
end: { x: finalWidth, y: offset - spacing / 2 },
|
||||
thickness: sepThickness,
|
||||
color: sepColor,
|
||||
});
|
||||
}
|
||||
offset -= spacing;
|
||||
} else {
|
||||
combinedPage.drawImage(pngImage, {
|
||||
x: offset,
|
||||
y: 0,
|
||||
width: dim.width,
|
||||
height: dim.height,
|
||||
});
|
||||
offset += dim.width;
|
||||
if (addSeparator && i < pdfjsDoc.numPages) {
|
||||
combinedPage.drawLine({
|
||||
start: { x: offset + spacing / 2, y: 0 },
|
||||
end: { x: offset + spacing / 2, y: finalHeight },
|
||||
thickness: sepThickness,
|
||||
color: sepColor,
|
||||
});
|
||||
}
|
||||
offset += spacing;
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_combined.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import {
|
||||
performCondenseCompression,
|
||||
performPhotonCompression,
|
||||
} from '../../utils/compress.js';
|
||||
import type { CondenseCustomSettings } from '../../utils/compress.js';
|
||||
import { isPyMuPDFAvailable } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class CompressNode extends BaseWorkflowNode {
|
||||
readonly category = 'Optimize & Repair' as const;
|
||||
readonly icon = 'ph-lightning';
|
||||
readonly description = 'Reduce PDF file size';
|
||||
|
||||
constructor() {
|
||||
super('Compress');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Compressed PDF')
|
||||
);
|
||||
this.addControl(
|
||||
'algorithm',
|
||||
new ClassicPreset.InputControl('text', { initial: 'condense' })
|
||||
);
|
||||
this.addControl(
|
||||
'compressionLevel',
|
||||
new ClassicPreset.InputControl('text', { initial: 'balanced' })
|
||||
);
|
||||
this.addControl(
|
||||
'imageQuality',
|
||||
new ClassicPreset.InputControl('number', { initial: 75 })
|
||||
);
|
||||
this.addControl(
|
||||
'dpiTarget',
|
||||
new ClassicPreset.InputControl('number', { initial: 96 })
|
||||
);
|
||||
this.addControl(
|
||||
'dpiThreshold',
|
||||
new ClassicPreset.InputControl('number', { initial: 150 })
|
||||
);
|
||||
this.addControl(
|
||||
'removeMetadata',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'subsetFonts',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'convertToGrayscale',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeThumbnails',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
}
|
||||
|
||||
private getTextControl(key: string, fallback: string): string {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value ?? fallback;
|
||||
}
|
||||
|
||||
private getNumberControl(key: string, fallback: number): number {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
return ctrl?.value ?? fallback;
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Compress');
|
||||
|
||||
const algorithm = this.getTextControl('algorithm', 'condense');
|
||||
const level = this.getTextControl('compressionLevel', 'balanced');
|
||||
const customSettings: CondenseCustomSettings = {
|
||||
imageQuality: this.getNumberControl('imageQuality', 75),
|
||||
dpiTarget: this.getNumberControl('dpiTarget', 96),
|
||||
dpiThreshold: this.getNumberControl('dpiThreshold', 150),
|
||||
removeMetadata: this.getTextControl('removeMetadata', 'true') === 'true',
|
||||
subsetFonts: this.getTextControl('subsetFonts', 'true') === 'true',
|
||||
convertToGrayscale:
|
||||
this.getTextControl('convertToGrayscale', 'false') === 'true',
|
||||
removeThumbnails:
|
||||
this.getTextControl('removeThumbnails', 'true') === 'true',
|
||||
};
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const arrayBuffer = input.bytes.buffer.slice(
|
||||
input.bytes.byteOffset,
|
||||
input.bytes.byteOffset + input.bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
|
||||
let pdfBytes: Uint8Array;
|
||||
|
||||
if (algorithm === 'condense' && isPyMuPDFAvailable()) {
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const result = await performCondenseCompression(
|
||||
blob,
|
||||
level,
|
||||
customSettings
|
||||
);
|
||||
pdfBytes = new Uint8Array(await result.blob.arrayBuffer());
|
||||
} else {
|
||||
pdfBytes = await performPhotonCompression(arrayBuffer, level);
|
||||
}
|
||||
|
||||
const document = await PDFDocument.load(pdfBytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes: pdfBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_compressed.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class CropNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-crop';
|
||||
readonly description = 'Trim margins from all pages';
|
||||
|
||||
constructor() {
|
||||
super('Crop');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Cropped PDF'));
|
||||
this.addControl(
|
||||
'top',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'bottom',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'left',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'right',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Crop');
|
||||
|
||||
const getNum = (key: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
return Math.max(0, ctrl?.value ?? 0);
|
||||
};
|
||||
|
||||
const top = getNum('top');
|
||||
const bottom = getNum('bottom');
|
||||
const left = getNum('left');
|
||||
const right = getNum('right');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfDoc = await PDFDocument.load(input.bytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const page of pages) {
|
||||
const { width, height } = page.getSize();
|
||||
const cropWidth = width - left - right;
|
||||
const cropHeight = height - top - bottom;
|
||||
if (cropWidth <= 0 || cropHeight <= 0) {
|
||||
throw new Error(
|
||||
'Crop margins exceed page dimensions. Reduce crop values.'
|
||||
);
|
||||
}
|
||||
page.setCropBox(left, bottom, cropWidth, cropHeight);
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_cropped.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { initializeQpdf } from '../../utils/helpers.js';
|
||||
|
||||
export class DecryptNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
readonly icon = 'ph-lock-open';
|
||||
readonly description = 'Remove PDF password protection';
|
||||
|
||||
constructor() {
|
||||
super('Decrypt');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Decrypted PDF'));
|
||||
this.addControl(
|
||||
'password',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Decrypt');
|
||||
|
||||
const passCtrl = this.controls['password'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const password = passCtrl?.value || '';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const qpdf = await initializeQpdf();
|
||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const inputPath = `/tmp/input_decrypt_${uid}.pdf`;
|
||||
const outputPath = `/tmp/output_decrypt_${uid}.pdf`;
|
||||
|
||||
let decryptedData: Uint8Array;
|
||||
try {
|
||||
qpdf.FS.writeFile(inputPath, input.bytes);
|
||||
const args = password
|
||||
? [inputPath, '--password=' + password, '--decrypt', outputPath]
|
||||
: [inputPath, '--decrypt', outputPath];
|
||||
qpdf.callMain(args);
|
||||
|
||||
decryptedData = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
} finally {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = new Uint8Array(decryptedData);
|
||||
const document = await PDFDocument.load(resultBytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_decrypted.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { deletePdfPages, parseDeletePages } from '../../utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class DeletePagesNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-trash';
|
||||
readonly description = 'Remove specific pages';
|
||||
|
||||
constructor() {
|
||||
super('Delete Pages');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'pages',
|
||||
new ClassicPreset.InputControl('text', { initial: '1' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Delete Pages');
|
||||
const pagesControl = this.controls['pages'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const deleteStr = pagesControl?.value || '';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const totalPages = srcDoc.getPageCount();
|
||||
const pagesToDelete = parseDeletePages(deleteStr, totalPages);
|
||||
const resultBytes = await deletePdfPages(input.bytes, pagesToDelete);
|
||||
const resultDoc = await PDFDocument.load(resultBytes);
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: resultDoc,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_trimmed.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class DeskewNode extends BaseWorkflowNode {
|
||||
readonly category = 'Optimize & Repair' as const;
|
||||
readonly icon = 'ph-perspective';
|
||||
readonly description = 'Straighten skewed PDF pages';
|
||||
|
||||
constructor() {
|
||||
super('Deskew');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Deskewed PDF'));
|
||||
this.addControl(
|
||||
'skewThreshold',
|
||||
new ClassicPreset.InputControl('text', { initial: '0.5' })
|
||||
);
|
||||
this.addControl(
|
||||
'processingDpi',
|
||||
new ClassicPreset.InputControl('text', { initial: '150' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Deskew');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
const threshold = parseFloat(getText('skewThreshold', '0.5'));
|
||||
const dpi = parseInt(getText('processingDpi', '150')) || 150;
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const blob = new Blob([new Uint8Array(input.bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const { pdf: resultPdf } = await pymupdf.deskewPdf(blob, {
|
||||
threshold,
|
||||
dpi,
|
||||
});
|
||||
|
||||
const bytes = new Uint8Array(await resultPdf.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_deskewed.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import {
|
||||
signPdf,
|
||||
parsePfxFile,
|
||||
parseCombinedPem,
|
||||
} from '../../logic/digital-sign-pdf.js';
|
||||
import type { CertificateData } from '@/types';
|
||||
|
||||
export class DigitalSignNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
readonly icon = 'ph-certificate';
|
||||
readonly description = 'Apply a digital signature to PDF';
|
||||
|
||||
private certFile: File | null = null;
|
||||
private certData: CertificateData | null = null;
|
||||
private certPassword: string = '';
|
||||
|
||||
constructor() {
|
||||
super('Digital Sign');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Signed PDF'));
|
||||
this.addControl(
|
||||
'reason',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'location',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'contactInfo',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
}
|
||||
|
||||
setCertFile(file: File): void {
|
||||
this.certFile = file;
|
||||
this.certData = null;
|
||||
}
|
||||
|
||||
getCertFilename(): string {
|
||||
return this.certFile?.name ?? '';
|
||||
}
|
||||
|
||||
hasCert(): boolean {
|
||||
return this.certData !== null;
|
||||
}
|
||||
|
||||
hasCertFile(): boolean {
|
||||
return this.certFile !== null;
|
||||
}
|
||||
|
||||
removeCert(): void {
|
||||
this.certFile = null;
|
||||
this.certData = null;
|
||||
this.certPassword = '';
|
||||
}
|
||||
|
||||
async unlockCert(password: string): Promise<boolean> {
|
||||
if (!this.certFile) return false;
|
||||
this.certPassword = password;
|
||||
|
||||
try {
|
||||
const isPem = this.certFile.name.toLowerCase().endsWith('.pem');
|
||||
if (isPem) {
|
||||
const pemContent = await this.certFile.text();
|
||||
this.certData = parseCombinedPem(pemContent, password || undefined);
|
||||
} else {
|
||||
const certBytes = await this.certFile.arrayBuffer();
|
||||
this.certData = parsePfxFile(certBytes, password);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
this.certData = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
needsPassword(): boolean {
|
||||
return this.certFile !== null && this.certData === null;
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Digital Sign');
|
||||
if (!this.certData)
|
||||
throw new Error('No certificate loaded in Digital Sign node');
|
||||
|
||||
const reasonCtrl = this.controls['reason'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const locationCtrl = this.controls['location'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const contactCtrl = this.controls['contactInfo'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
|
||||
const reason = reasonCtrl?.value ?? '';
|
||||
const location = locationCtrl?.value ?? '';
|
||||
const contactInfo = contactCtrl?.value ?? '';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const signedBytes = await signPdf(input.bytes, this.certData!, {
|
||||
signatureInfo: {
|
||||
...(reason ? { reason } : {}),
|
||||
...(location ? { location } : {}),
|
||||
...(contactInfo ? { contactInfo } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const bytes = new Uint8Array(signedBytes);
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_signed.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class DividePagesNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-columns';
|
||||
readonly description = 'Split pages vertically or horizontally';
|
||||
|
||||
constructor() {
|
||||
super('Divide Pages');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Divided PDF'));
|
||||
this.addControl(
|
||||
'direction',
|
||||
new ClassicPreset.InputControl('text', { initial: 'vertical' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Divide Pages');
|
||||
const dirCtrl = this.controls['direction'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const direction =
|
||||
dirCtrl?.value === 'horizontal' ? 'horizontal' : 'vertical';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const newDoc = await PDFDocument.create();
|
||||
for (let i = 0; i < srcDoc.getPageCount(); i++) {
|
||||
const [page1] = await newDoc.copyPages(srcDoc, [i]);
|
||||
const [page2] = await newDoc.copyPages(srcDoc, [i]);
|
||||
const { width, height } = page1.getSize();
|
||||
if (direction === 'vertical') {
|
||||
page1.setCropBox(0, 0, width / 2, height);
|
||||
page2.setCropBox(width / 2, 0, width / 2, height);
|
||||
} else {
|
||||
page1.setCropBox(0, height / 2, width, height / 2);
|
||||
page2.setCropBox(0, 0, width, height / 2);
|
||||
}
|
||||
newDoc.addPage(page1);
|
||||
newDoc.addPage(page2);
|
||||
}
|
||||
const pdfBytes = await newDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_divided.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
|
||||
export class DownloadNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-download-simple';
|
||||
readonly description = 'Download as PDF or ZIP automatically';
|
||||
|
||||
constructor() {
|
||||
super('Download');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF', true));
|
||||
this.addControl(
|
||||
'filename',
|
||||
new ClassicPreset.InputControl('text', {
|
||||
initial: 'output',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const allInputs = Object.values(inputs).flat();
|
||||
const allPdfs = extractAllPdfs(allInputs);
|
||||
if (allPdfs.length === 0)
|
||||
throw new Error('No PDFs connected to Download node');
|
||||
|
||||
const filenameControl = this.controls['filename'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const baseName = filenameControl?.value || 'output';
|
||||
|
||||
if (allPdfs.length === 1) {
|
||||
const filename = baseName.toLowerCase().endsWith('.pdf')
|
||||
? baseName
|
||||
: `${baseName}.pdf`;
|
||||
const blob = new Blob([new Uint8Array(allPdfs[0].bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, filename);
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
const usedNames = new Set<string>();
|
||||
for (const pdf of allPdfs) {
|
||||
let name = pdf.filename || 'document.pdf';
|
||||
if (!name.toLowerCase().endsWith('.pdf')) name += '.pdf';
|
||||
let uniqueName = name;
|
||||
let counter = 1;
|
||||
while (usedNames.has(uniqueName)) {
|
||||
uniqueName = name.replace(/\.pdf$/i, `_${counter}.pdf`);
|
||||
counter++;
|
||||
}
|
||||
usedNames.add(uniqueName);
|
||||
zip.file(uniqueName, pdf.bytes);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const zipFilename = baseName.toLowerCase().endsWith('.zip')
|
||||
? baseName
|
||||
: `${baseName}.zip`;
|
||||
downloadFile(zipBlob, zipFilename);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
|
||||
export class DownloadPDFNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-download-simple';
|
||||
readonly description = 'Download the resulting PDF';
|
||||
|
||||
constructor() {
|
||||
super('Download PDF');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'filename',
|
||||
new ClassicPreset.InputControl('text', {
|
||||
initial: 'output.pdf',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Download PDF');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
|
||||
const filenameControl = this.controls['filename'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const filename = filenameControl?.value || 'output.pdf';
|
||||
|
||||
if (allPdfs.length === 1) {
|
||||
const blob = new Blob([new Uint8Array(allPdfs[0].bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, filename);
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
const usedNames = new Set<string>();
|
||||
for (const pdf of allPdfs) {
|
||||
let name = pdf.filename || 'document.pdf';
|
||||
if (!name.toLowerCase().endsWith('.pdf')) name += '.pdf';
|
||||
let uniqueName = name;
|
||||
let counter = 1;
|
||||
while (usedNames.has(uniqueName)) {
|
||||
uniqueName = name.replace(/\.pdf$/i, `_${counter}.pdf`);
|
||||
counter++;
|
||||
}
|
||||
usedNames.add(uniqueName);
|
||||
zip.file(uniqueName, pdf.bytes);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, filename.replace(/\.pdf$/i, '.zip'));
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
|
||||
export class DownloadZipNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-archive';
|
||||
readonly description = 'Download multiple PDFs as ZIP';
|
||||
|
||||
constructor() {
|
||||
super('Download ZIP');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDFs', true));
|
||||
this.addControl(
|
||||
'filename',
|
||||
new ClassicPreset.InputControl('text', { initial: 'output.zip' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const allInputs = Object.values(inputs).flat();
|
||||
const allPdfs = extractAllPdfs(allInputs);
|
||||
if (allPdfs.length === 0)
|
||||
throw new Error('No PDFs connected to Download ZIP node');
|
||||
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
for (const pdf of allPdfs) {
|
||||
let name = pdf.filename || 'document.pdf';
|
||||
if (!name.toLowerCase().endsWith('.pdf')) name += '.pdf';
|
||||
let uniqueName = name;
|
||||
let counter = 1;
|
||||
while (usedNames.has(uniqueName)) {
|
||||
uniqueName = name.replace(/\.pdf$/i, `_${counter}.pdf`);
|
||||
counter++;
|
||||
}
|
||||
usedNames.add(uniqueName);
|
||||
zip.file(uniqueName, pdf.bytes);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
|
||||
const filenameCtrl = this.controls['filename'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const filename = filenameCtrl?.value || 'output.zip';
|
||||
downloadFile(zipBlob, filename);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class EditMetadataNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-file-code';
|
||||
readonly description = 'Edit PDF metadata';
|
||||
|
||||
constructor() {
|
||||
super('Edit Metadata');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'title',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'author',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'subject',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'keywords',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'creator',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'producer',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Edit Metadata');
|
||||
|
||||
const getText = (key: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || '';
|
||||
};
|
||||
|
||||
const title = getText('title');
|
||||
const author = getText('author');
|
||||
const subject = getText('subject');
|
||||
const keywords = getText('keywords');
|
||||
const creator = getText('creator');
|
||||
const producer = getText('producer');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfDoc = await PDFDocument.load(input.bytes);
|
||||
|
||||
if (title) pdfDoc.setTitle(title);
|
||||
if (author) pdfDoc.setAuthor(author);
|
||||
if (subject) pdfDoc.setSubject(subject);
|
||||
if (keywords)
|
||||
pdfDoc.setKeywords(
|
||||
keywords
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (creator) pdfDoc.setCreator(creator);
|
||||
if (producer) pdfDoc.setProducer(producer);
|
||||
pdfDoc.setModificationDate(new Date());
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_metadata.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { parseEmailFile, renderEmailToHtml } from '../../logic/email-to-pdf.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class EmailToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-envelope';
|
||||
readonly description = 'Upload email files (.eml, .msg) and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('Email Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'pageSize',
|
||||
new ClassicPreset.InputControl('text', { initial: 'a4' })
|
||||
);
|
||||
this.addControl(
|
||||
'includeCcBcc',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'includeAttachments',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith('.eml') || name.endsWith('.msg')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} email files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No email files uploaded in Email Input node');
|
||||
|
||||
const pageSizeCtrl = this.controls['pageSize'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const ccBccCtrl = this.controls['includeCcBcc'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const attachCtrl = this.controls['includeAttachments'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
|
||||
const pageSize = (pageSizeCtrl?.value ?? 'a4') as 'a4' | 'letter' | 'legal';
|
||||
const includeCcBcc = (ccBccCtrl?.value ?? 'true') === 'true';
|
||||
const includeAttachments = (attachCtrl?.value ?? 'true') === 'true';
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const email = await parseEmailFile(file);
|
||||
const htmlContent = renderEmailToHtml(email, {
|
||||
includeCcBcc,
|
||||
includeAttachments,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
const pdfBlob = await (pymupdf as any).htmlToPdf(htmlContent, {
|
||||
pageSize,
|
||||
margins: { top: 50, right: 50, bottom: 50, left: 50 },
|
||||
attachments: email.attachments
|
||||
.filter((a) => a.content)
|
||||
.map((a) => ({
|
||||
filename: a.filename,
|
||||
content: a.content!,
|
||||
})),
|
||||
});
|
||||
|
||||
const bytes = new Uint8Array(await pdfBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.[^.]+$/, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { initializeQpdf } from '../../utils/helpers.js';
|
||||
|
||||
export class EncryptNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
readonly icon = 'ph-lock';
|
||||
readonly description = 'Encrypt PDF with password';
|
||||
|
||||
constructor() {
|
||||
super('Encrypt');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Encrypted PDF'));
|
||||
this.addControl(
|
||||
'userPassword',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'ownerPassword',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Encrypt');
|
||||
|
||||
const getText = (key: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || '';
|
||||
};
|
||||
|
||||
const userPassword = getText('userPassword');
|
||||
const ownerPassword = getText('ownerPassword') || userPassword;
|
||||
if (!userPassword)
|
||||
throw new Error('User password is required for encryption');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const qpdf = await initializeQpdf();
|
||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const inputPath = `/tmp/input_encrypt_${uid}.pdf`;
|
||||
const outputPath = `/tmp/output_encrypt_${uid}.pdf`;
|
||||
|
||||
let encryptedData: Uint8Array;
|
||||
try {
|
||||
qpdf.FS.writeFile(inputPath, input.bytes);
|
||||
|
||||
const args = [
|
||||
inputPath,
|
||||
'--encrypt',
|
||||
userPassword,
|
||||
ownerPassword,
|
||||
'256',
|
||||
];
|
||||
if (ownerPassword !== userPassword) {
|
||||
args.push(
|
||||
'--modify=none',
|
||||
'--extract=n',
|
||||
'--print=none',
|
||||
'--accessibility=n',
|
||||
'--annotate=n',
|
||||
'--assemble=n',
|
||||
'--form=n',
|
||||
'--modify-other=n'
|
||||
);
|
||||
}
|
||||
args.push('--', outputPath);
|
||||
qpdf.callMain(args);
|
||||
|
||||
encryptedData = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
} finally {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = new Uint8Array(encryptedData);
|
||||
const document = await PDFDocument.load(resultBytes, {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_encrypted.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class EpubToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-book-open-text';
|
||||
readonly description = 'Upload EPUB and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('EPUB Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.endsWith('.epub')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} EPUB files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No EPUB files uploaded in EPUB Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const blob = await pymupdf.convertToPdf(file, { filetype: 'epub' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.epub$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class ExcelToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-microsoft-excel-logo';
|
||||
readonly description = 'Upload Excel spreadsheet and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('Excel Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const ext = file.name.toLowerCase();
|
||||
if (
|
||||
ext.endsWith('.xlsx') ||
|
||||
ext.endsWith('.xls') ||
|
||||
ext.endsWith('.ods') ||
|
||||
ext.endsWith('.csv')
|
||||
) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} spreadsheets`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No spreadsheets uploaded in Excel Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.[^.]+$/, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class ExtractImagesNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-download-simple';
|
||||
readonly description = 'Extract all images from PDF';
|
||||
|
||||
constructor() {
|
||||
super('Extract Images');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Extract Images');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
let totalImages = 0;
|
||||
|
||||
for (const pdf of allPdfs) {
|
||||
const blob = new Blob([new Uint8Array(pdf.bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const doc = await pymupdf.open(blob);
|
||||
const pageCount = doc.pageCount;
|
||||
const prefix =
|
||||
allPdfs.length > 1 ? pdf.filename.replace(/\.pdf$/i, '') + '/' : '';
|
||||
|
||||
for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) {
|
||||
const page = doc.getPage(pageIdx);
|
||||
const images = page.getImages();
|
||||
|
||||
for (const imgInfo of images) {
|
||||
try {
|
||||
const imgData = page.extractImage(imgInfo.xref);
|
||||
if (imgData && imgData.data) {
|
||||
totalImages++;
|
||||
zip.file(
|
||||
`${prefix}image_${totalImages}.${imgData.ext || 'png'}`,
|
||||
imgData.data
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.close();
|
||||
}
|
||||
|
||||
if (totalImages === 0) {
|
||||
throw new Error('No images found in any of the connected PDFs');
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'extracted_images.zip');
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData, MultiPDFData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { parsePageRange } from '../../utils/pdf-operations';
|
||||
|
||||
export class ExtractPagesNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-squares-four';
|
||||
readonly description = 'Extract pages as separate PDFs';
|
||||
|
||||
constructor() {
|
||||
super('Extract Pages');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Extracted PDFs')
|
||||
);
|
||||
this.addControl(
|
||||
'pages',
|
||||
new ClassicPreset.InputControl('text', { initial: '1,2,3' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Extract Pages');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
|
||||
const pagesCtrl = this.controls['pages'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const rangeStr = pagesCtrl?.value || '1';
|
||||
|
||||
const allItems = [];
|
||||
for (const input of allPdfs) {
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const totalPages = srcDoc.getPageCount();
|
||||
const indices = parsePageRange(rangeStr, totalPages);
|
||||
|
||||
for (const idx of indices) {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const [copiedPage] = await newPdf.copyPages(srcDoc, [idx]);
|
||||
newPdf.addPage(copiedPage);
|
||||
const pdfBytes = await newPdf.save();
|
||||
allItems.push({
|
||||
type: 'pdf' as const,
|
||||
document: newPdf,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: `page_${idx + 1}.pdf`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (allItems.length === 1) {
|
||||
return { pdf: allItems[0] };
|
||||
}
|
||||
|
||||
const result: MultiPDFData = {
|
||||
type: 'multi-pdf',
|
||||
items: allItems,
|
||||
};
|
||||
return { pdf: result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class Fb2ToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-book-bookmark';
|
||||
readonly description = 'Upload FB2 e-books and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('FB2 Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.fb2')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} FB2 files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No FB2 files uploaded in FB2 Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const blob = await pymupdf.convertToPdf(file, { filetype: 'fb2' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.fb2$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class FlattenNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
readonly icon = 'ph-stack';
|
||||
readonly description = 'Flatten forms and annotations';
|
||||
|
||||
constructor() {
|
||||
super('Flatten');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Flattened PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Flatten');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfDoc = await PDFDocument.load(input.bytes);
|
||||
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
} catch (err) {
|
||||
console.error('Flatten form error (may have no forms):', err);
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_flattened.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { convertFontsToOutlines } from '../../utils/ghostscript-loader.js';
|
||||
|
||||
export class FontToOutlineNode extends BaseWorkflowNode {
|
||||
readonly category = 'Optimize & Repair' as const;
|
||||
readonly icon = 'ph-text-outdent';
|
||||
readonly description = 'Convert fonts to vector outlines';
|
||||
|
||||
constructor() {
|
||||
super('Font to Outline');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Outlined PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Font to Outline');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const resultBytes = await convertFontsToOutlines(
|
||||
new Uint8Array(input.bytes)
|
||||
);
|
||||
const bytes = new Uint8Array(resultBytes);
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_outline.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { applyGreyscale } from '../../utils/image-effects';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
export class GreyscaleNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-palette';
|
||||
readonly description = 'Convert to greyscale';
|
||||
|
||||
constructor() {
|
||||
super('Greyscale');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Greyscale PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Greyscale');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: input.bytes })
|
||||
.promise;
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx)
|
||||
throw new Error(`Failed to get canvas context for page ${i}`);
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
applyGreyscale(imageData);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const jpegBlob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', 0.9)
|
||||
);
|
||||
|
||||
if (!jpegBlob) throw new Error(`Failed to render page ${i} to image`);
|
||||
const jpegBytes = await jpegBlob.arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
}
|
||||
|
||||
if (newPdfDoc.getPageCount() === 0)
|
||||
throw new Error('No pages were processed');
|
||||
const pdfBytes = await newPdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newPdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_greyscale.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
|
||||
export class HeaderFooterNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-paragraph';
|
||||
readonly description = 'Add header and footer text';
|
||||
|
||||
constructor() {
|
||||
super('Header & Footer');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'PDF with Header/Footer')
|
||||
);
|
||||
this.addControl(
|
||||
'headerLeft',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'headerCenter',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'headerRight',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'footerLeft',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'footerCenter',
|
||||
new ClassicPreset.InputControl('text', {
|
||||
initial: 'Page {page} of {total}',
|
||||
})
|
||||
);
|
||||
this.addControl(
|
||||
'footerRight',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
this.addControl(
|
||||
'fontSize',
|
||||
new ClassicPreset.InputControl('number', { initial: 10 })
|
||||
);
|
||||
this.addControl(
|
||||
'color',
|
||||
new ClassicPreset.InputControl('text', { initial: '#000000' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Header & Footer');
|
||||
|
||||
const getText = (key: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || '';
|
||||
};
|
||||
const sizeCtrl = this.controls['fontSize'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const fontSize = Math.max(4, Math.min(72, sizeCtrl?.value ?? 10));
|
||||
|
||||
const colorHex = getText('color') || '#000000';
|
||||
const c = hexToRgb(colorHex);
|
||||
const color = rgb(c.r, c.g, c.b);
|
||||
|
||||
const fields = {
|
||||
headerLeft: getText('headerLeft'),
|
||||
headerCenter: getText('headerCenter'),
|
||||
headerRight: getText('headerRight'),
|
||||
footerLeft: getText('footerLeft'),
|
||||
footerCenter: getText('footerCenter'),
|
||||
footerRight: getText('footerRight'),
|
||||
};
|
||||
|
||||
const hasAny = Object.values(fields).some((v) => v.length > 0);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
if (!hasAny) return input;
|
||||
|
||||
const pdfDoc = await PDFDocument.load(input.bytes);
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const pages = pdfDoc.getPages();
|
||||
const totalPages = pages.length;
|
||||
const margin = 36;
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const { width, height } = page.getSize();
|
||||
const pageNum = i + 1;
|
||||
|
||||
const processText = (tmpl: string) =>
|
||||
tmpl
|
||||
.replace(/{page}/g, String(pageNum))
|
||||
.replace(/{total}/g, String(totalPages));
|
||||
|
||||
const drawOpts = { size: fontSize, font, color };
|
||||
|
||||
if (fields.headerLeft) {
|
||||
page.drawText(processText(fields.headerLeft), {
|
||||
...drawOpts,
|
||||
x: margin,
|
||||
y: height - margin,
|
||||
});
|
||||
}
|
||||
if (fields.headerCenter) {
|
||||
const text = processText(fields.headerCenter);
|
||||
const tw = font.widthOfTextAtSize(text, fontSize);
|
||||
page.drawText(text, {
|
||||
...drawOpts,
|
||||
x: (width - tw) / 2,
|
||||
y: height - margin,
|
||||
});
|
||||
}
|
||||
if (fields.headerRight) {
|
||||
const text = processText(fields.headerRight);
|
||||
const tw = font.widthOfTextAtSize(text, fontSize);
|
||||
page.drawText(text, {
|
||||
...drawOpts,
|
||||
x: width - margin - tw,
|
||||
y: height - margin,
|
||||
});
|
||||
}
|
||||
if (fields.footerLeft) {
|
||||
page.drawText(processText(fields.footerLeft), {
|
||||
...drawOpts,
|
||||
x: margin,
|
||||
y: margin - fontSize,
|
||||
});
|
||||
}
|
||||
if (fields.footerCenter) {
|
||||
const text = processText(fields.footerCenter);
|
||||
const tw = font.widthOfTextAtSize(text, fontSize);
|
||||
page.drawText(text, {
|
||||
...drawOpts,
|
||||
x: (width - tw) / 2,
|
||||
y: margin - fontSize,
|
||||
});
|
||||
}
|
||||
if (fields.footerRight) {
|
||||
const text = processText(fields.footerRight);
|
||||
const tw = font.widthOfTextAtSize(text, fontSize);
|
||||
page.drawText(text, {
|
||||
...drawOpts,
|
||||
x: width - margin - tw,
|
||||
y: margin - fontSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_hf.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class ImageInputNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-image';
|
||||
readonly description = 'Upload images and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('Image Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} images`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0) {
|
||||
throw new Error('No images uploaded in Image Input node');
|
||||
}
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const pdfBlob = await pymupdf.imagesToPdf(this.files);
|
||||
const bytes = new Uint8Array(await pdfBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
const result: PDFData = {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: 'images.pdf',
|
||||
};
|
||||
|
||||
return { pdf: result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { applyInvertColors } from '../../utils/image-effects';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
export class InvertColorsNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-circle-half';
|
||||
readonly description = 'Invert all colors';
|
||||
|
||||
constructor() {
|
||||
super('Invert Colors');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Inverted PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Invert Colors');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: input.bytes })
|
||||
.promise;
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx)
|
||||
throw new Error(`Failed to get canvas context for page ${i}`);
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
applyInvertColors(imageData);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const pngBytes = await new Promise<Uint8Array>((resolve, reject) =>
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error(`Failed to render page ${i}`));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
reader.onerror = () =>
|
||||
reject(new Error('Failed to read image data'));
|
||||
reader.readAsArrayBuffer(blob);
|
||||
}, 'image/png')
|
||||
);
|
||||
|
||||
const image = await newPdfDoc.embedPng(pngBytes);
|
||||
const newPage = newPdfDoc.addPage([image.width, image.height]);
|
||||
newPage.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = await newPdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newPdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_inverted.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class JsonToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-file-code';
|
||||
readonly description = 'Upload JSON files and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('JSON Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.json')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} JSON files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No JSON files uploaded in JSON Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const rawText = await file.text();
|
||||
let formatted: string;
|
||||
try {
|
||||
formatted = JSON.stringify(JSON.parse(rawText), null, 2);
|
||||
} catch {
|
||||
formatted = rawText;
|
||||
}
|
||||
const pdfBlob = await pymupdf.textToPdf(formatted, {
|
||||
fontSize: 10,
|
||||
fontName: 'cour',
|
||||
pageSize: 'a4',
|
||||
});
|
||||
const bytes = new Uint8Array(await pdfBlob.arrayBuffer());
|
||||
const pdfDoc = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.json$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { initializeQpdf } from '../../utils/helpers.js';
|
||||
|
||||
export class LinearizeNode extends BaseWorkflowNode {
|
||||
readonly category = 'Optimize & Repair' as const;
|
||||
readonly icon = 'ph-gauge';
|
||||
readonly description = 'Linearize PDF for fast web viewing';
|
||||
|
||||
constructor() {
|
||||
super('Linearize');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Linearized PDF')
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Linearize');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const qpdf = await initializeQpdf();
|
||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const inputPath = `/tmp/input_linearize_${uid}.pdf`;
|
||||
const outputPath = `/tmp/output_linearize_${uid}.pdf`;
|
||||
|
||||
let resultBytes: Uint8Array;
|
||||
try {
|
||||
qpdf.FS.writeFile(inputPath, input.bytes);
|
||||
qpdf.callMain([inputPath, '--linearize', outputPath]);
|
||||
resultBytes = new Uint8Array(qpdf.FS.readFile(outputPath));
|
||||
} finally {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
}
|
||||
|
||||
const document = await PDFDocument.load(resultBytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_linearized.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
|
||||
|
||||
export class MarkdownToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-markdown-logo';
|
||||
readonly description = 'Upload Markdown files and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('Markdown Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith('.md') || name.endsWith('.markdown')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} Markdown files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No Markdown files uploaded in Markdown Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const textContent = await file.text();
|
||||
const htmlContent = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: sans-serif; font-size: 12pt; line-height: 1.6; max-width: 100%; padding: 0; }
|
||||
h1 { font-size: 24pt; } h2 { font-size: 20pt; } h3 { font-size: 16pt; }
|
||||
code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-size: 0.9em; }
|
||||
pre code { display: block; padding: 12px; overflow-x: auto; }
|
||||
blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 12px; color: #555; }
|
||||
table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ccc; padding: 6px 12px; }
|
||||
</style></head><body>${md.render(textContent)}</body></html>`;
|
||||
const pdfBlob = await (pymupdf as any).htmlToPdf(htmlContent, {
|
||||
pageSize: 'a4',
|
||||
});
|
||||
const bytes = new Uint8Array(await pdfBlob.arrayBuffer());
|
||||
const pdfDoc = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.(md|markdown)$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { extractAllPdfs } from '../types';
|
||||
import { mergePdfs } from '../../utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class MergeNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-browsers';
|
||||
readonly description = 'Combine multiple PDFs into one';
|
||||
|
||||
constructor() {
|
||||
super('Merge PDFs');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDFs', true));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Merged PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const allInputs = Object.values(inputs).flat();
|
||||
const allPdfs = extractAllPdfs(allInputs);
|
||||
if (allPdfs.length === 0)
|
||||
throw new Error('No PDFs connected to Merge node');
|
||||
|
||||
const mergedBytes = await mergePdfs(allPdfs.map((p) => p.bytes));
|
||||
const mergedDoc = await PDFDocument.load(mergedBytes);
|
||||
|
||||
return {
|
||||
pdf: {
|
||||
type: 'pdf',
|
||||
document: mergedDoc,
|
||||
bytes: mergedBytes,
|
||||
filename: 'merged.pdf',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class MobiToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-book-open-text';
|
||||
readonly description = 'Upload MOBI e-books and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('MOBI Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.mobi')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} MOBI files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No MOBI files uploaded in MOBI Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const blob = await pymupdf.convertToPdf(file, { filetype: 'mobi' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.mobi$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
|
||||
export class NUpNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-squares-four';
|
||||
readonly description = 'Arrange multiple pages per sheet';
|
||||
|
||||
constructor() {
|
||||
super('N-Up');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'N-Up PDF'));
|
||||
this.addControl(
|
||||
'pagesPerSheet',
|
||||
new ClassicPreset.InputControl('number', { initial: 4 })
|
||||
);
|
||||
this.addControl(
|
||||
'orientation',
|
||||
new ClassicPreset.InputControl('text', { initial: 'auto' })
|
||||
);
|
||||
this.addControl(
|
||||
'margins',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'border',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
this.addControl(
|
||||
'borderColor',
|
||||
new ClassicPreset.InputControl('text', { initial: '#000000' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'N-Up');
|
||||
|
||||
const nCtrl = this.controls['pagesPerSheet'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const n = [2, 4, 9, 16].includes(nCtrl?.value ?? 4)
|
||||
? (nCtrl?.value ?? 4)
|
||||
: 4;
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
|
||||
const orientation = getText('orientation', 'auto');
|
||||
const useMargins = getText('margins', 'true') === 'true';
|
||||
const addBorder = getText('border', 'false') === 'true';
|
||||
const borderHex = getText('borderColor', '#000000');
|
||||
const bc = hexToRgb(borderHex);
|
||||
const borderColor = rgb(bc.r, bc.g, bc.b);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const gridDims: Record<number, [number, number]> = {
|
||||
2: [2, 1],
|
||||
4: [2, 2],
|
||||
9: [3, 3],
|
||||
16: [4, 4],
|
||||
};
|
||||
const [cols, rows] = gridDims[n];
|
||||
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const newDoc = await PDFDocument.create();
|
||||
const pageCount = srcDoc.getPageCount();
|
||||
const firstPage = srcDoc.getPages()[0];
|
||||
let { width: pageWidth, height: pageHeight } = firstPage.getSize();
|
||||
|
||||
if (orientation === 'landscape' && pageWidth < pageHeight) {
|
||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
} else if (orientation === 'portrait' && pageWidth > pageHeight) {
|
||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
|
||||
const margin = useMargins ? 36 : 0;
|
||||
const gutter = useMargins ? 5 : 0;
|
||||
const usableWidth = pageWidth - margin * 2;
|
||||
const usableHeight = pageHeight - margin * 2;
|
||||
const cellWidth = (usableWidth - gutter * (cols - 1)) / cols;
|
||||
const cellHeight = (usableHeight - gutter * (rows - 1)) / rows;
|
||||
|
||||
for (let start = 0; start < pageCount; start += n) {
|
||||
const outputPage = newDoc.addPage([pageWidth, pageHeight]);
|
||||
const chunk = Math.min(n, pageCount - start);
|
||||
|
||||
for (let j = 0; j < chunk; j++) {
|
||||
const srcPage = srcDoc.getPages()[start + j];
|
||||
const embedded = await newDoc.embedPage(srcPage);
|
||||
const col = j % cols;
|
||||
const row = Math.floor(j / cols);
|
||||
const x = margin + col * (cellWidth + gutter);
|
||||
const y =
|
||||
pageHeight - margin - (row + 1) * cellHeight - row * gutter;
|
||||
|
||||
outputPage.drawPage(embedded, {
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
});
|
||||
|
||||
if (addBorder) {
|
||||
outputPage.drawRectangle({
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
borderColor,
|
||||
borderWidth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_nup.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { performOcr } from '../../utils/ocr';
|
||||
|
||||
export class OCRNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-barcode';
|
||||
readonly description = 'Add searchable text layer via OCR';
|
||||
|
||||
constructor() {
|
||||
super('OCR');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Searchable PDF')
|
||||
);
|
||||
this.addControl(
|
||||
'language',
|
||||
new ClassicPreset.InputControl('text', { initial: 'eng' })
|
||||
);
|
||||
this.addControl(
|
||||
'resolution',
|
||||
new ClassicPreset.InputControl('text', { initial: '3.0' })
|
||||
);
|
||||
this.addControl(
|
||||
'binarize',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
this.addControl(
|
||||
'whitelist',
|
||||
new ClassicPreset.InputControl('text', { initial: '' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'OCR');
|
||||
|
||||
const langCtrl = this.controls['language'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const language = langCtrl?.value || 'eng';
|
||||
|
||||
const resCtrl = this.controls['resolution'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const resolution = Math.max(
|
||||
1.0,
|
||||
Math.min(4.0, parseFloat(resCtrl?.value ?? '3.0'))
|
||||
);
|
||||
|
||||
const binarizeCtrl = this.controls['binarize'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const binarize = (binarizeCtrl?.value ?? 'false') === 'true';
|
||||
|
||||
const whitelistCtrl = this.controls['whitelist'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const whitelist = whitelistCtrl?.value || '';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const result = await performOcr(input.bytes, {
|
||||
language,
|
||||
resolution,
|
||||
binarize,
|
||||
whitelist,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: result.pdfDoc,
|
||||
bytes: result.pdfBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_ocr.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class OdgToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-image';
|
||||
readonly description = 'Upload OpenDocument Graphics and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('ODG Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.odg')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} ODG files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No ODG files uploaded in ODG Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.odg$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import {
|
||||
addPageNumbers,
|
||||
type PageNumberPosition,
|
||||
type PageNumberFormat,
|
||||
} from '../../utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
|
||||
export class PageNumbersNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-list-numbers';
|
||||
readonly description = 'Add page numbers';
|
||||
|
||||
constructor() {
|
||||
super('Page Numbers');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Numbered PDF'));
|
||||
this.addControl(
|
||||
'position',
|
||||
new ClassicPreset.InputControl('text', { initial: 'bottom-center' })
|
||||
);
|
||||
this.addControl(
|
||||
'fontSize',
|
||||
new ClassicPreset.InputControl('number', { initial: 12 })
|
||||
);
|
||||
this.addControl(
|
||||
'numberFormat',
|
||||
new ClassicPreset.InputControl('text', { initial: 'simple' })
|
||||
);
|
||||
this.addControl(
|
||||
'color',
|
||||
new ClassicPreset.InputControl('text', { initial: '#000000' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Page Numbers');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
const sizeCtrl = this.controls['fontSize'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const fontSize = Math.max(4, Math.min(72, sizeCtrl?.value ?? 12));
|
||||
|
||||
const position = getText('position', 'bottom-center') as PageNumberPosition;
|
||||
const format = getText('numberFormat', 'simple') as PageNumberFormat;
|
||||
const colorHex = getText('color', '#000000');
|
||||
const c = hexToRgb(colorHex);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const resultBytes = await addPageNumbers(input.bytes, {
|
||||
position,
|
||||
fontSize,
|
||||
format,
|
||||
color: { r: c.r, g: c.g, b: c.b },
|
||||
});
|
||||
|
||||
const resultDoc = await PDFDocument.load(resultBytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: resultDoc,
|
||||
bytes: new Uint8Array(resultBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_numbered.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class PagesToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-file-text';
|
||||
readonly description = 'Upload Apple Pages documents and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('Pages Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.pages')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} Pages files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No Pages files uploaded in Pages Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.pages$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { readFileAsArrayBuffer } from '../../utils/helpers.js';
|
||||
|
||||
export class PDFInputNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-file-pdf';
|
||||
readonly description = 'Upload one or more PDF files';
|
||||
|
||||
private files: PDFData[] = [];
|
||||
|
||||
constructor() {
|
||||
super('PDF Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFile(file: File): Promise<void> {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
const bytes = new Uint8Array(arrayBuffer as ArrayBuffer);
|
||||
const document = await PDFDocument.load(bytes, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
this.files.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name,
|
||||
});
|
||||
}
|
||||
|
||||
async setFile(file: File): Promise<void> {
|
||||
this.files = [];
|
||||
await this.addFile(file);
|
||||
}
|
||||
|
||||
async setFiles(fileList: File[]): Promise<void> {
|
||||
this.files = [];
|
||||
for (const file of fileList) {
|
||||
await this.addFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.filename);
|
||||
}
|
||||
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].filename;
|
||||
return `${this.files.length} files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0) {
|
||||
throw new Error('No PDF files uploaded in PDF Input node');
|
||||
}
|
||||
|
||||
if (this.files.length === 1) {
|
||||
return { pdf: this.files[0] };
|
||||
}
|
||||
|
||||
const multiData: MultiPDFData = {
|
||||
type: 'multi-pdf',
|
||||
items: this.files,
|
||||
};
|
||||
return { pdf: multiData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
function tableToCsv(rows: (string | null)[][]): string {
|
||||
return rows
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell) => {
|
||||
const str = String(cell ?? '');
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
})
|
||||
.join(',')
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export class PdfToCsvNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-file-csv';
|
||||
readonly description = 'Extract tables from PDF to CSV';
|
||||
|
||||
constructor() {
|
||||
super('PDF to CSV');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
private async extractTables(bytes: Uint8Array): Promise<(string | null)[][]> {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/pdf' });
|
||||
const doc = await pymupdf.open(blob);
|
||||
const allRows: (string | null)[][] = [];
|
||||
|
||||
try {
|
||||
const pageCount = doc.pageCount;
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const page = doc.getPage(i);
|
||||
const tables = page.findTables();
|
||||
tables.forEach((table: any) => {
|
||||
allRows.push(...table.rows);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
|
||||
return allRows;
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to CSV');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
|
||||
if (allPdfs.length === 1) {
|
||||
const allRows = await this.extractTables(allPdfs[0].bytes);
|
||||
if (allRows.length === 0) {
|
||||
throw new Error('No tables found in PDF');
|
||||
}
|
||||
const csv = tableToCsv(allRows);
|
||||
const csvBlob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const name = allPdfs[0].filename.replace(/\.pdf$/i, '') + '.csv';
|
||||
downloadFile(csvBlob, name);
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
for (const pdf of allPdfs) {
|
||||
const allRows = await this.extractTables(pdf.bytes);
|
||||
if (allRows.length === 0) continue;
|
||||
const csv = tableToCsv(allRows);
|
||||
const name = pdf.filename.replace(/\.pdf$/i, '') + '.csv';
|
||||
zip.file(name, csv);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'csv_files.zip');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class PdfToDocxNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-microsoft-word-logo';
|
||||
readonly description = 'Convert PDF to Word document';
|
||||
|
||||
constructor() {
|
||||
super('PDF to Word');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to Word');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
|
||||
if (allPdfs.length === 1) {
|
||||
const blob = new Blob([new Uint8Array(allPdfs[0].bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const docxBlob = await pymupdf.pdfToDocx(blob);
|
||||
const name = allPdfs[0].filename.replace(/\.pdf$/i, '') + '.docx';
|
||||
downloadFile(docxBlob, name);
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
for (const pdf of allPdfs) {
|
||||
const blob = new Blob([new Uint8Array(pdf.bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const docxBlob = await pymupdf.pdfToDocx(blob);
|
||||
const name = pdf.filename.replace(/\.pdf$/i, '') + '.docx';
|
||||
const arrayBuffer = await docxBlob.arrayBuffer();
|
||||
zip.file(name, arrayBuffer);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'docx_files.zip');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData, PDFData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class PdfToImagesNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-file-image';
|
||||
readonly description = 'Convert PDF pages to images (ZIP)';
|
||||
|
||||
constructor() {
|
||||
super('PDF to Images');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'format',
|
||||
new ClassicPreset.InputControl('text', { initial: 'jpg' })
|
||||
);
|
||||
this.addControl(
|
||||
'quality',
|
||||
new ClassicPreset.InputControl('number', { initial: 90 })
|
||||
);
|
||||
this.addControl(
|
||||
'dpi',
|
||||
new ClassicPreset.InputControl('number', { initial: 150 })
|
||||
);
|
||||
}
|
||||
|
||||
private async addPdfPages(
|
||||
pdf: PDFData,
|
||||
zip: any,
|
||||
format: string,
|
||||
mimeType: string,
|
||||
quality: number,
|
||||
scale: number,
|
||||
prefix: string
|
||||
): Promise<void> {
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: pdf.bytes }).promise;
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, mimeType, quality)
|
||||
);
|
||||
// Release canvas memory
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
if (blob) {
|
||||
zip.file(`${prefix}page_${i}.${format}`, blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async addPdfPagesAsSvg(allPdfs: PDFData[], zip: any): Promise<void> {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
for (const pdf of allPdfs) {
|
||||
const blob = new Blob([new Uint8Array(pdf.bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const doc = await pymupdf.open(blob);
|
||||
try {
|
||||
const pageCount = doc.pageCount;
|
||||
const prefix =
|
||||
allPdfs.length > 1 ? pdf.filename.replace(/\.pdf$/i, '') + '/' : '';
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const page = doc.getPage(i);
|
||||
const svg = page.toSvg();
|
||||
zip.file(`${prefix}page_${i + 1}.svg`, svg);
|
||||
}
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to Images');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
|
||||
const fmtCtrl = this.controls['format'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const format = ['jpg', 'png', 'webp', 'svg'].includes(fmtCtrl?.value ?? '')
|
||||
? (fmtCtrl?.value ?? 'jpg')
|
||||
: 'jpg';
|
||||
|
||||
const qualCtrl = this.controls['quality'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const quality = Math.max(10, Math.min(100, qualCtrl?.value ?? 90)) / 100;
|
||||
|
||||
const dpiCtrl = this.controls['dpi'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const dpi = Math.max(72, Math.min(600, dpiCtrl?.value ?? 150));
|
||||
const scale = dpi / 72;
|
||||
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
const mimeType = mimeMap[format] || 'image/jpeg';
|
||||
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
if (format === 'svg') {
|
||||
await this.addPdfPagesAsSvg(allPdfs, zip);
|
||||
} else if (allPdfs.length === 1) {
|
||||
await this.addPdfPages(
|
||||
allPdfs[0],
|
||||
zip,
|
||||
format,
|
||||
mimeType,
|
||||
quality,
|
||||
scale,
|
||||
''
|
||||
);
|
||||
} else {
|
||||
for (const pdf of allPdfs) {
|
||||
const prefix = pdf.filename.replace(/\.pdf$/i, '') + '/';
|
||||
await this.addPdfPages(
|
||||
pdf,
|
||||
zip,
|
||||
format,
|
||||
mimeType,
|
||||
quality,
|
||||
scale,
|
||||
prefix
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'images.zip');
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class PdfToMarkdownNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-markdown-logo';
|
||||
readonly description = 'Extract text from PDF as Markdown';
|
||||
|
||||
constructor() {
|
||||
super('PDF to Markdown');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
private async extractMarkdown(bytes: Uint8Array): Promise<string> {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/pdf' });
|
||||
return pymupdf.pdfToMarkdown(blob, { includeImages: false });
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to Markdown');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
|
||||
if (allPdfs.length === 1) {
|
||||
const markdown = await this.extractMarkdown(allPdfs[0].bytes);
|
||||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||
const name = allPdfs[0].filename.replace(/\.pdf$/i, '') + '.md';
|
||||
downloadFile(blob, name);
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
for (const pdf of allPdfs) {
|
||||
const markdown = await this.extractMarkdown(pdf.bytes);
|
||||
const name = pdf.filename.replace(/\.pdf$/i, '') + '.md';
|
||||
zip.file(name, markdown);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'markdown_files.zip');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadGhostscript } from '../../utils/ghostscript-dynamic-loader.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class PdfToPdfANode extends BaseWorkflowNode {
|
||||
readonly category = 'Optimize & Repair' as const;
|
||||
readonly icon = 'ph-archive';
|
||||
readonly description = 'Convert PDF to PDF/A for archiving';
|
||||
|
||||
constructor() {
|
||||
super('PDF to PDF/A');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF/A'));
|
||||
this.addControl(
|
||||
'level',
|
||||
new ClassicPreset.InputControl('text', { initial: 'PDF/A-2b' })
|
||||
);
|
||||
this.addControl(
|
||||
'preFlatten',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to PDF/A');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
|
||||
const level = getText('level', 'PDF/A-2b');
|
||||
const preFlatten = getText('preFlatten', 'false') === 'true';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
let pdfBytes = input.bytes;
|
||||
|
||||
if (preFlatten) {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const blob = new Blob([new Uint8Array(pdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const flattenedBlob = await pymupdf.rasterizePdf(blob, {
|
||||
dpi: 300,
|
||||
format: 'png',
|
||||
});
|
||||
pdfBytes = new Uint8Array(await flattenedBlob.arrayBuffer());
|
||||
}
|
||||
|
||||
const gs = await loadGhostscript();
|
||||
const pdfBuffer = pdfBytes.buffer.slice(
|
||||
pdfBytes.byteOffset,
|
||||
pdfBytes.byteOffset + pdfBytes.byteLength
|
||||
);
|
||||
const resultBuffer = await gs.convertToPDFA(
|
||||
pdfBuffer as ArrayBuffer,
|
||||
level
|
||||
);
|
||||
|
||||
const bytes = new Uint8Array(resultBuffer);
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_pdfa.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class PdfToSvgNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-file-code';
|
||||
readonly description = 'Convert PDF pages to SVG';
|
||||
|
||||
constructor() {
|
||||
super('PDF to SVG');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to SVG');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const pdf of allPdfs) {
|
||||
const blob = new Blob([new Uint8Array(pdf.bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const doc = await pymupdf.open(blob);
|
||||
try {
|
||||
const pageCount = doc.pageCount;
|
||||
const prefix =
|
||||
allPdfs.length > 1 ? pdf.filename.replace(/\.pdf$/i, '') + '/' : '';
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const page = doc.getPage(i);
|
||||
const svg = page.toSvg();
|
||||
zip.file(`${prefix}page_${i + 1}.svg`, svg);
|
||||
}
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'svg_pages.zip');
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class PdfToTextNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-text-aa';
|
||||
readonly description = 'Extract text from PDF';
|
||||
|
||||
constructor() {
|
||||
super('PDF to Text');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
private async extractText(bytes: Uint8Array): Promise<string> {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/pdf' });
|
||||
return pymupdf.pdfToText(blob);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to Text');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
|
||||
if (allPdfs.length === 1) {
|
||||
const text = await this.extractText(allPdfs[0].bytes);
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const name = allPdfs[0].filename.replace(/\.pdf$/i, '') + '.txt';
|
||||
downloadFile(blob, name);
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
for (const pdf of allPdfs) {
|
||||
const text = await this.extractText(pdf.bytes);
|
||||
const name = pdf.filename.replace(/\.pdf$/i, '') + '.txt';
|
||||
zip.file(name, text);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'text_files.zip');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, extractAllPdfs } from '../types';
|
||||
import { downloadFile } from '../../utils/helpers.js';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class PdfToXlsxNode extends BaseWorkflowNode {
|
||||
readonly category = 'Output' as const;
|
||||
readonly icon = 'ph-microsoft-excel-logo';
|
||||
readonly description = 'Extract tables from PDF to Excel';
|
||||
|
||||
constructor() {
|
||||
super('PDF to Excel');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
private async convertToXlsx(
|
||||
bytes: Uint8Array,
|
||||
filename: string
|
||||
): Promise<{ blob: Blob; name: string }> {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/pdf' });
|
||||
const doc = await pymupdf.open(blob);
|
||||
|
||||
interface TableData {
|
||||
page: number;
|
||||
rows: (string | null)[][];
|
||||
}
|
||||
|
||||
const allTables: TableData[] = [];
|
||||
|
||||
try {
|
||||
const pageCount = doc.pageCount;
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const page = doc.getPage(i);
|
||||
const tables = page.findTables();
|
||||
tables.forEach((table: any) => {
|
||||
allTables.push({ page: i + 1, rows: table.rows });
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
|
||||
if (allTables.length === 0) {
|
||||
throw new Error(`No tables found in ${filename}`);
|
||||
}
|
||||
|
||||
const XLSX = await import('xlsx');
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
if (allTables.length === 1) {
|
||||
const ws = XLSX.utils.aoa_to_sheet(allTables[0].rows);
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Table');
|
||||
} else {
|
||||
allTables.forEach((table, idx) => {
|
||||
const sheetName = `Table ${idx + 1} (Page ${table.page})`.substring(
|
||||
0,
|
||||
31
|
||||
);
|
||||
const ws = XLSX.utils.aoa_to_sheet(table.rows);
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
});
|
||||
}
|
||||
|
||||
const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||
const xlsxBlob = new Blob([xlsxBytes], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
const name = filename.replace(/\.pdf$/i, '') + '.xlsx';
|
||||
return { blob: xlsxBlob, name };
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'PDF to Excel');
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
|
||||
if (allPdfs.length === 1) {
|
||||
const { blob, name } = await this.convertToXlsx(
|
||||
allPdfs[0].bytes,
|
||||
allPdfs[0].filename
|
||||
);
|
||||
downloadFile(blob, name);
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
for (const pdf of allPdfs) {
|
||||
const { blob, name } = await this.convertToXlsx(
|
||||
pdf.bytes,
|
||||
pdf.filename
|
||||
);
|
||||
zip.file(name, blob);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'xlsx_files.zip');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
export class PosterizeNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-notepad';
|
||||
readonly description = 'Split pages into tile grid for poster printing';
|
||||
|
||||
constructor() {
|
||||
super('Posterize');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Posterized PDF')
|
||||
);
|
||||
this.addControl(
|
||||
'rows',
|
||||
new ClassicPreset.InputControl('number', { initial: 2 })
|
||||
);
|
||||
this.addControl(
|
||||
'cols',
|
||||
new ClassicPreset.InputControl('number', { initial: 2 })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Posterize');
|
||||
|
||||
const rowsCtrl = this.controls['rows'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const colsCtrl = this.controls['cols'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const rows = Math.max(1, Math.min(8, rowsCtrl?.value ?? 2));
|
||||
const cols = Math.max(1, Math.min(8, colsCtrl?.value ?? 2));
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: input.bytes })
|
||||
.promise;
|
||||
const newDoc = await PDFDocument.create();
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2.0 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx)
|
||||
throw new Error(`Failed to get canvas context for page ${i}`);
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
const tileW = viewport.width / cols;
|
||||
const tileH = viewport.height / rows;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const tileCanvas = document.createElement('canvas');
|
||||
tileCanvas.width = tileW;
|
||||
tileCanvas.height = tileH;
|
||||
const tileCtx = tileCanvas.getContext('2d');
|
||||
if (!tileCtx)
|
||||
throw new Error('Failed to get tile canvas context');
|
||||
tileCtx.drawImage(
|
||||
canvas,
|
||||
c * tileW,
|
||||
r * tileH,
|
||||
tileW,
|
||||
tileH,
|
||||
0,
|
||||
0,
|
||||
tileW,
|
||||
tileH
|
||||
);
|
||||
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) =>
|
||||
tileCanvas.toBlob(resolve, 'image/png')
|
||||
);
|
||||
if (!pngBlob)
|
||||
throw new Error(
|
||||
`Failed to render tile (row ${r}, col ${c}) of page ${i}`
|
||||
);
|
||||
const pngBytes = new Uint8Array(await pngBlob.arrayBuffer());
|
||||
const pngImage = await newDoc.embedPng(pngBytes);
|
||||
const newPage = newDoc.addPage([tileW / 2, tileH / 2]);
|
||||
newPage.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: tileW / 2,
|
||||
height: tileH / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = new Uint8Array(await newDoc.save());
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newDoc,
|
||||
bytes: pdfBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_posterized.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class PowerPointToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-microsoft-powerpoint-logo';
|
||||
readonly description = 'Upload PowerPoint and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('PowerPoint Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const ext = file.name.toLowerCase();
|
||||
if (
|
||||
ext.endsWith('.ppt') ||
|
||||
ext.endsWith('.pptx') ||
|
||||
ext.endsWith('.odp')
|
||||
) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} presentations`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No presentations uploaded in PowerPoint Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.[^.]+$/, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class PubToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-book-open';
|
||||
readonly description = 'Upload Microsoft Publisher files and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('PUB Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.pub')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} PUB files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No PUB files uploaded in PUB Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.pub$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class RasterizeNode extends BaseWorkflowNode {
|
||||
readonly category = 'Optimize & Repair' as const;
|
||||
readonly icon = 'ph-image';
|
||||
readonly description = 'Convert to image-based PDF';
|
||||
|
||||
constructor() {
|
||||
super('Rasterize');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Rasterized PDF')
|
||||
);
|
||||
this.addControl(
|
||||
'rasterizeDpi',
|
||||
new ClassicPreset.InputControl('text', { initial: '150' })
|
||||
);
|
||||
this.addControl(
|
||||
'imageFormat',
|
||||
new ClassicPreset.InputControl('text', { initial: 'png' })
|
||||
);
|
||||
this.addControl(
|
||||
'grayscale',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Rasterize');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
|
||||
const dpi = Math.max(
|
||||
72,
|
||||
Math.min(600, parseInt(getText('rasterizeDpi', '150')) || 150)
|
||||
);
|
||||
const format = getText('imageFormat', 'png') === 'jpeg' ? 'jpeg' : 'png';
|
||||
const grayscale = getText('grayscale', 'false') === 'true';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const blob = new Blob([new Uint8Array(input.bytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
const rasterizedBlob = await pymupdf.rasterizePdf(blob, {
|
||||
dpi,
|
||||
format,
|
||||
grayscale,
|
||||
quality: 95,
|
||||
});
|
||||
|
||||
const bytes = new Uint8Array(await rasterizedBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_rasterized.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import type { BaseWorkflowNode } from './base-node';
|
||||
import type { NodeCategory } from '../types';
|
||||
import { PDFInputNode } from './pdf-input-node';
|
||||
import { ImageInputNode } from './image-input-node';
|
||||
import { DownloadNode } from './download-node';
|
||||
import { PdfToImagesNode } from './pdf-to-images-node';
|
||||
import { MergeNode } from './merge-node';
|
||||
import { SplitNode } from './split-node';
|
||||
import { ExtractPagesNode } from './extract-pages-node';
|
||||
import { RotateNode } from './rotate-node';
|
||||
import { DeletePagesNode } from './delete-pages-node';
|
||||
import { ReversePagesNode } from './reverse-pages-node';
|
||||
import { AddBlankPageNode } from './add-blank-page-node';
|
||||
import { DividePagesNode } from './divide-pages-node';
|
||||
import { NUpNode } from './n-up-node';
|
||||
import { CombineSinglePageNode } from './combine-single-page-node';
|
||||
import { CropNode } from './crop-node';
|
||||
import { GreyscaleNode } from './greyscale-node';
|
||||
import { InvertColorsNode } from './invert-colors-node';
|
||||
import { ScannerEffectNode } from './scanner-effect-node';
|
||||
import { AdjustColorsNode } from './adjust-colors-node';
|
||||
import { BackgroundColorNode } from './background-color-node';
|
||||
import { WatermarkNode } from './watermark-node';
|
||||
import { PageNumbersNode } from './page-numbers-node';
|
||||
import { HeaderFooterNode } from './header-footer-node';
|
||||
import { CompressNode } from './compress-node';
|
||||
import { RasterizeNode } from './rasterize-node';
|
||||
import { OCRNode } from './ocr-node';
|
||||
import { RemoveBlankPagesNode } from './remove-blank-pages-node';
|
||||
import { RemoveAnnotationsNode } from './remove-annotations-node';
|
||||
import { FlattenNode } from './flatten-node';
|
||||
import { EditMetadataNode } from './edit-metadata-node';
|
||||
import { SanitizeNode } from './sanitize-node';
|
||||
import { EncryptNode } from './encrypt-node';
|
||||
import { DecryptNode } from './decrypt-node';
|
||||
import { DigitalSignNode } from './digital-sign-node';
|
||||
import { RepairNode } from './repair-node';
|
||||
import { PdfToTextNode } from './pdf-to-text-node';
|
||||
import { PdfToDocxNode } from './pdf-to-docx-node';
|
||||
import { PdfToXlsxNode } from './pdf-to-xlsx-node';
|
||||
import { PdfToCsvNode } from './pdf-to-csv-node';
|
||||
import { PdfToSvgNode } from './pdf-to-svg-node';
|
||||
import { PdfToMarkdownNode } from './pdf-to-markdown-node';
|
||||
import { ExtractImagesNode } from './extract-images-node';
|
||||
import { WordToPdfNode } from './word-to-pdf-node';
|
||||
import { ExcelToPdfNode } from './excel-to-pdf-node';
|
||||
import { PowerPointToPdfNode } from './powerpoint-to-pdf-node';
|
||||
import { TextToPdfNode } from './text-to-pdf-node';
|
||||
import { SvgToPdfNode } from './svg-to-pdf-node';
|
||||
import { EpubToPdfNode } from './epub-to-pdf-node';
|
||||
import { LinearizeNode } from './linearize-node';
|
||||
import { DeskewNode } from './deskew-node';
|
||||
import { PdfToPdfANode } from './pdf-to-pdfa-node';
|
||||
import { PosterizeNode } from './posterize-node';
|
||||
import { BookletNode } from './booklet-node';
|
||||
import { FontToOutlineNode } from './font-to-outline-node';
|
||||
import { TableOfContentsNode } from './table-of-contents-node';
|
||||
import { EmailToPdfNode } from './email-to-pdf-node';
|
||||
import { XpsToPdfNode } from './xps-to-pdf-node';
|
||||
import { MobiToPdfNode } from './mobi-to-pdf-node';
|
||||
import { Fb2ToPdfNode } from './fb2-to-pdf-node';
|
||||
import { CbzToPdfNode } from './cbz-to-pdf-node';
|
||||
import { MarkdownToPdfNode } from './markdown-to-pdf-node';
|
||||
import { JsonToPdfNode } from './json-to-pdf-node';
|
||||
import { XmlToPdfNode } from './xml-to-pdf-node';
|
||||
import { WpdToPdfNode } from './wpd-to-pdf-node';
|
||||
import { WpsToPdfNode } from './wps-to-pdf-node';
|
||||
import { PagesToPdfNode } from './pages-to-pdf-node';
|
||||
import { OdgToPdfNode } from './odg-to-pdf-node';
|
||||
import { PubToPdfNode } from './pub-to-pdf-node';
|
||||
import { VsdToPdfNode } from './vsd-to-pdf-node';
|
||||
|
||||
export interface NodeRegistryEntry {
|
||||
label: string;
|
||||
category: NodeCategory;
|
||||
icon: string;
|
||||
description: string;
|
||||
factory: () => BaseWorkflowNode;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const nodeRegistry: Record<string, NodeRegistryEntry> = {
|
||||
PDFInputNode: {
|
||||
label: 'PDF Input',
|
||||
category: 'Input',
|
||||
icon: 'ph-file-pdf',
|
||||
description: 'Upload one or more PDF files',
|
||||
factory: () => new PDFInputNode(),
|
||||
},
|
||||
ImageInputNode: {
|
||||
label: 'Image Input',
|
||||
category: 'Input',
|
||||
icon: 'ph-image',
|
||||
description: 'Upload images and convert to PDF',
|
||||
factory: () => new ImageInputNode(),
|
||||
},
|
||||
WordToPdfNode: {
|
||||
label: 'Word to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-microsoft-word-logo',
|
||||
description: 'Convert Word documents to PDF',
|
||||
factory: () => new WordToPdfNode(),
|
||||
},
|
||||
ExcelToPdfNode: {
|
||||
label: 'Excel to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-microsoft-excel-logo',
|
||||
description: 'Convert Excel spreadsheets to PDF',
|
||||
factory: () => new ExcelToPdfNode(),
|
||||
},
|
||||
PowerPointToPdfNode: {
|
||||
label: 'PowerPoint to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-microsoft-powerpoint-logo',
|
||||
description: 'Convert PowerPoint presentations to PDF',
|
||||
factory: () => new PowerPointToPdfNode(),
|
||||
},
|
||||
TextToPdfNode: {
|
||||
label: 'Text to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-text-t',
|
||||
description: 'Convert plain text to PDF',
|
||||
factory: () => new TextToPdfNode(),
|
||||
},
|
||||
SvgToPdfNode: {
|
||||
label: 'SVG to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-file-svg',
|
||||
description: 'Convert SVG files to PDF',
|
||||
factory: () => new SvgToPdfNode(),
|
||||
},
|
||||
EpubToPdfNode: {
|
||||
label: 'EPUB to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-book-open-text',
|
||||
description: 'Convert EPUB ebooks to PDF',
|
||||
factory: () => new EpubToPdfNode(),
|
||||
},
|
||||
EmailToPdfNode: {
|
||||
label: 'Email to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-envelope',
|
||||
description: 'Convert email files (.eml, .msg) to PDF',
|
||||
factory: () => new EmailToPdfNode(),
|
||||
},
|
||||
XpsToPdfNode: {
|
||||
label: 'XPS to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-scan',
|
||||
description: 'Convert XPS/OXPS documents to PDF',
|
||||
factory: () => new XpsToPdfNode(),
|
||||
},
|
||||
MobiToPdfNode: {
|
||||
label: 'MOBI to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-book-open-text',
|
||||
description: 'Convert MOBI e-books to PDF',
|
||||
factory: () => new MobiToPdfNode(),
|
||||
},
|
||||
Fb2ToPdfNode: {
|
||||
label: 'FB2 to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-book-bookmark',
|
||||
description: 'Convert FB2 e-books to PDF',
|
||||
factory: () => new Fb2ToPdfNode(),
|
||||
},
|
||||
CbzToPdfNode: {
|
||||
label: 'CBZ to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-book-open',
|
||||
description: 'Convert comic book archives (CBZ/CBR) to PDF',
|
||||
factory: () => new CbzToPdfNode(),
|
||||
},
|
||||
MarkdownToPdfNode: {
|
||||
label: 'Markdown to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-markdown-logo',
|
||||
description: 'Convert Markdown files to PDF',
|
||||
factory: () => new MarkdownToPdfNode(),
|
||||
},
|
||||
JsonToPdfNode: {
|
||||
label: 'JSON to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-file-code',
|
||||
description: 'Convert JSON files to PDF',
|
||||
factory: () => new JsonToPdfNode(),
|
||||
},
|
||||
XmlToPdfNode: {
|
||||
label: 'XML to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-file-code',
|
||||
description: 'Convert XML documents to PDF',
|
||||
factory: () => new XmlToPdfNode(),
|
||||
},
|
||||
WpdToPdfNode: {
|
||||
label: 'WPD to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-file-text',
|
||||
description: 'Convert WordPerfect documents to PDF',
|
||||
factory: () => new WpdToPdfNode(),
|
||||
},
|
||||
WpsToPdfNode: {
|
||||
label: 'WPS to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-file-text',
|
||||
description: 'Convert WPS Office documents to PDF',
|
||||
factory: () => new WpsToPdfNode(),
|
||||
},
|
||||
PagesToPdfNode: {
|
||||
label: 'Pages to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-file-text',
|
||||
description: 'Convert Apple Pages documents to PDF',
|
||||
factory: () => new PagesToPdfNode(),
|
||||
},
|
||||
OdgToPdfNode: {
|
||||
label: 'ODG to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-image',
|
||||
description: 'Convert OpenDocument Graphics to PDF',
|
||||
factory: () => new OdgToPdfNode(),
|
||||
},
|
||||
PubToPdfNode: {
|
||||
label: 'PUB to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-book-open',
|
||||
description: 'Convert Microsoft Publisher to PDF',
|
||||
factory: () => new PubToPdfNode(),
|
||||
},
|
||||
VsdToPdfNode: {
|
||||
label: 'VSD to PDF',
|
||||
category: 'Input',
|
||||
icon: 'ph-git-branch',
|
||||
description: 'Convert Visio diagrams (VSD/VSDX) to PDF',
|
||||
factory: () => new VsdToPdfNode(),
|
||||
},
|
||||
MergeNode: {
|
||||
label: 'Merge PDFs',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-browsers',
|
||||
description: 'Combine multiple PDFs into one',
|
||||
factory: () => new MergeNode(),
|
||||
},
|
||||
SplitNode: {
|
||||
label: 'Split PDF',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-scissors',
|
||||
description: 'Extract a range of pages',
|
||||
factory: () => new SplitNode(),
|
||||
},
|
||||
ExtractPagesNode: {
|
||||
label: 'Extract Pages',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-squares-four',
|
||||
description: 'Extract pages as separate PDFs',
|
||||
factory: () => new ExtractPagesNode(),
|
||||
},
|
||||
RotateNode: {
|
||||
label: 'Rotate',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-arrow-clockwise',
|
||||
description: 'Rotate all pages',
|
||||
factory: () => new RotateNode(),
|
||||
},
|
||||
DeletePagesNode: {
|
||||
label: 'Delete Pages',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-trash',
|
||||
description: 'Remove specific pages',
|
||||
factory: () => new DeletePagesNode(),
|
||||
},
|
||||
ReversePagesNode: {
|
||||
label: 'Reverse Pages',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-sort-descending',
|
||||
description: 'Reverse page order',
|
||||
factory: () => new ReversePagesNode(),
|
||||
},
|
||||
AddBlankPageNode: {
|
||||
label: 'Add Blank Page',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-file-plus',
|
||||
description: 'Insert blank pages',
|
||||
factory: () => new AddBlankPageNode(),
|
||||
},
|
||||
DividePagesNode: {
|
||||
label: 'Divide Pages',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-columns',
|
||||
description: 'Split pages vertically or horizontally',
|
||||
factory: () => new DividePagesNode(),
|
||||
},
|
||||
NUpNode: {
|
||||
label: 'N-Up',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-squares-four',
|
||||
description: 'Arrange multiple pages per sheet',
|
||||
factory: () => new NUpNode(),
|
||||
},
|
||||
CombineSinglePageNode: {
|
||||
label: 'Combine to Single Page',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-arrows-out-line-vertical',
|
||||
description: 'Stitch all pages into one continuous page',
|
||||
factory: () => new CombineSinglePageNode(),
|
||||
},
|
||||
BookletNode: {
|
||||
label: 'Booklet',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-book-open',
|
||||
description: 'Arrange pages for booklet printing',
|
||||
factory: () => new BookletNode(),
|
||||
},
|
||||
PosterizeNode: {
|
||||
label: 'Posterize',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-notepad',
|
||||
description: 'Split pages into tile grid for poster printing',
|
||||
factory: () => new PosterizeNode(),
|
||||
},
|
||||
EditMetadataNode: {
|
||||
label: 'Edit Metadata',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-file-code',
|
||||
description: 'Edit PDF metadata',
|
||||
factory: () => new EditMetadataNode(),
|
||||
},
|
||||
TableOfContentsNode: {
|
||||
label: 'Table of Contents',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-list',
|
||||
description: 'Generate table of contents from bookmarks',
|
||||
factory: () => new TableOfContentsNode(),
|
||||
},
|
||||
OCRNode: {
|
||||
label: 'OCR',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-barcode',
|
||||
description: 'Add searchable text layer via OCR',
|
||||
factory: () => new OCRNode(),
|
||||
},
|
||||
CropNode: {
|
||||
label: 'Crop',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-crop',
|
||||
description: 'Trim margins from all pages',
|
||||
factory: () => new CropNode(),
|
||||
},
|
||||
GreyscaleNode: {
|
||||
label: 'Greyscale',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-palette',
|
||||
description: 'Convert to greyscale',
|
||||
factory: () => new GreyscaleNode(),
|
||||
},
|
||||
InvertColorsNode: {
|
||||
label: 'Invert Colors',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-circle-half',
|
||||
description: 'Invert all colors',
|
||||
factory: () => new InvertColorsNode(),
|
||||
},
|
||||
ScannerEffectNode: {
|
||||
label: 'Scanner Effect',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-scan',
|
||||
description: 'Apply scanner simulation effect',
|
||||
factory: () => new ScannerEffectNode(),
|
||||
},
|
||||
AdjustColorsNode: {
|
||||
label: 'Adjust Colors',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-sliders-horizontal',
|
||||
description: 'Adjust brightness, contrast, and colors',
|
||||
factory: () => new AdjustColorsNode(),
|
||||
},
|
||||
BackgroundColorNode: {
|
||||
label: 'Background Color',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-palette',
|
||||
description: 'Change background color',
|
||||
factory: () => new BackgroundColorNode(),
|
||||
},
|
||||
WatermarkNode: {
|
||||
label: 'Watermark',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-drop',
|
||||
description: 'Add text watermark',
|
||||
factory: () => new WatermarkNode(),
|
||||
},
|
||||
PageNumbersNode: {
|
||||
label: 'Page Numbers',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-list-numbers',
|
||||
description: 'Add page numbers',
|
||||
factory: () => new PageNumbersNode(),
|
||||
},
|
||||
HeaderFooterNode: {
|
||||
label: 'Header & Footer',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-paragraph',
|
||||
description: 'Add header and footer text',
|
||||
factory: () => new HeaderFooterNode(),
|
||||
},
|
||||
RemoveBlankPagesNode: {
|
||||
label: 'Remove Blank Pages',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-file-minus',
|
||||
description: 'Remove blank pages automatically',
|
||||
factory: () => new RemoveBlankPagesNode(),
|
||||
},
|
||||
RemoveAnnotationsNode: {
|
||||
label: 'Remove Annotations',
|
||||
category: 'Edit & Annotate',
|
||||
icon: 'ph-eraser',
|
||||
description: 'Strip all annotations',
|
||||
factory: () => new RemoveAnnotationsNode(),
|
||||
},
|
||||
CompressNode: {
|
||||
label: 'Compress',
|
||||
category: 'Optimize & Repair',
|
||||
icon: 'ph-lightning',
|
||||
description: 'Reduce PDF file size',
|
||||
factory: () => new CompressNode(),
|
||||
},
|
||||
RasterizeNode: {
|
||||
label: 'Rasterize',
|
||||
category: 'Optimize & Repair',
|
||||
icon: 'ph-image',
|
||||
description: 'Convert to image-based PDF',
|
||||
factory: () => new RasterizeNode(),
|
||||
},
|
||||
LinearizeNode: {
|
||||
label: 'Linearize',
|
||||
category: 'Optimize & Repair',
|
||||
icon: 'ph-gauge',
|
||||
description: 'Optimize PDF for fast web viewing',
|
||||
factory: () => new LinearizeNode(),
|
||||
},
|
||||
DeskewNode: {
|
||||
label: 'Deskew',
|
||||
category: 'Optimize & Repair',
|
||||
icon: 'ph-perspective',
|
||||
description: 'Straighten skewed PDF pages',
|
||||
factory: () => new DeskewNode(),
|
||||
},
|
||||
PdfToPdfANode: {
|
||||
label: 'PDF to PDF/A',
|
||||
category: 'Optimize & Repair',
|
||||
icon: 'ph-archive',
|
||||
description: 'Convert PDF to PDF/A for archiving',
|
||||
factory: () => new PdfToPdfANode(),
|
||||
},
|
||||
FontToOutlineNode: {
|
||||
label: 'Font to Outline',
|
||||
category: 'Optimize & Repair',
|
||||
icon: 'ph-text-outdent',
|
||||
description: 'Convert fonts to vector outlines',
|
||||
factory: () => new FontToOutlineNode(),
|
||||
},
|
||||
RepairNode: {
|
||||
label: 'Repair',
|
||||
category: 'Optimize & Repair',
|
||||
icon: 'ph-wrench',
|
||||
description: 'Repair corrupted PDF',
|
||||
factory: () => new RepairNode(),
|
||||
},
|
||||
EncryptNode: {
|
||||
label: 'Encrypt',
|
||||
category: 'Secure PDF',
|
||||
icon: 'ph-lock',
|
||||
description: 'Encrypt PDF with password',
|
||||
factory: () => new EncryptNode(),
|
||||
},
|
||||
DecryptNode: {
|
||||
label: 'Decrypt',
|
||||
category: 'Secure PDF',
|
||||
icon: 'ph-lock-open',
|
||||
description: 'Remove PDF password protection',
|
||||
factory: () => new DecryptNode(),
|
||||
},
|
||||
SanitizeNode: {
|
||||
label: 'Sanitize',
|
||||
category: 'Secure PDF',
|
||||
icon: 'ph-broom',
|
||||
description: 'Remove metadata, scripts, and hidden data',
|
||||
factory: () => new SanitizeNode(),
|
||||
},
|
||||
FlattenNode: {
|
||||
label: 'Flatten',
|
||||
category: 'Secure PDF',
|
||||
icon: 'ph-stack',
|
||||
description: 'Flatten forms and annotations',
|
||||
factory: () => new FlattenNode(),
|
||||
},
|
||||
DigitalSignNode: {
|
||||
label: 'Digital Sign',
|
||||
category: 'Secure PDF',
|
||||
icon: 'ph-certificate',
|
||||
description: 'Apply a digital signature to PDF',
|
||||
factory: () => new DigitalSignNode(),
|
||||
},
|
||||
DownloadNode: {
|
||||
label: 'Download',
|
||||
category: 'Output',
|
||||
icon: 'ph-download-simple',
|
||||
description: 'Download as PDF or ZIP automatically',
|
||||
factory: () => new DownloadNode(),
|
||||
},
|
||||
// Backward compat for saved workflows
|
||||
DownloadPDFNode: {
|
||||
label: 'Download',
|
||||
category: 'Output',
|
||||
icon: 'ph-download-simple',
|
||||
description: 'Download as PDF or ZIP automatically',
|
||||
factory: () => new DownloadNode(),
|
||||
hidden: true,
|
||||
},
|
||||
DownloadZipNode: {
|
||||
label: 'Download',
|
||||
category: 'Output',
|
||||
icon: 'ph-download-simple',
|
||||
description: 'Download as PDF or ZIP automatically',
|
||||
factory: () => new DownloadNode(),
|
||||
hidden: true,
|
||||
},
|
||||
PdfToImagesNode: {
|
||||
label: 'PDF to Images',
|
||||
category: 'Output',
|
||||
icon: 'ph-file-image',
|
||||
description: 'Convert PDF pages to images (ZIP)',
|
||||
factory: () => new PdfToImagesNode(),
|
||||
},
|
||||
PdfToTextNode: {
|
||||
label: 'PDF to Text',
|
||||
category: 'Output',
|
||||
icon: 'ph-text-aa',
|
||||
description: 'Extract text from PDF',
|
||||
factory: () => new PdfToTextNode(),
|
||||
},
|
||||
PdfToDocxNode: {
|
||||
label: 'PDF to DOCX',
|
||||
category: 'Output',
|
||||
icon: 'ph-microsoft-word-logo',
|
||||
description: 'Convert PDF to Word document',
|
||||
factory: () => new PdfToDocxNode(),
|
||||
},
|
||||
PdfToXlsxNode: {
|
||||
label: 'PDF to XLSX',
|
||||
category: 'Output',
|
||||
icon: 'ph-microsoft-excel-logo',
|
||||
description: 'Convert PDF tables to Excel',
|
||||
factory: () => new PdfToXlsxNode(),
|
||||
},
|
||||
PdfToCsvNode: {
|
||||
label: 'PDF to CSV',
|
||||
category: 'Output',
|
||||
icon: 'ph-file-csv',
|
||||
description: 'Convert PDF tables to CSV',
|
||||
factory: () => new PdfToCsvNode(),
|
||||
},
|
||||
PdfToSvgNode: {
|
||||
label: 'PDF to SVG',
|
||||
category: 'Output',
|
||||
icon: 'ph-file-code',
|
||||
description: 'Convert PDF pages to SVG',
|
||||
factory: () => new PdfToSvgNode(),
|
||||
},
|
||||
PdfToMarkdownNode: {
|
||||
label: 'PDF to Markdown',
|
||||
category: 'Output',
|
||||
icon: 'ph-markdown-logo',
|
||||
description: 'Convert PDF to Markdown text',
|
||||
factory: () => new PdfToMarkdownNode(),
|
||||
},
|
||||
ExtractImagesNode: {
|
||||
label: 'Extract Images',
|
||||
category: 'Output',
|
||||
icon: 'ph-download-simple',
|
||||
description: 'Extract all images from PDF',
|
||||
factory: () => new ExtractImagesNode(),
|
||||
},
|
||||
};
|
||||
|
||||
export function createNodeByType(type: string): BaseWorkflowNode | null {
|
||||
const entry = nodeRegistry[type];
|
||||
if (!entry) return null;
|
||||
return entry.factory();
|
||||
}
|
||||
|
||||
export function getNodesByCategory(): Record<
|
||||
NodeCategory,
|
||||
NodeRegistryEntry[]
|
||||
> {
|
||||
const result: Record<NodeCategory, NodeRegistryEntry[]> = {
|
||||
Input: [],
|
||||
'Edit & Annotate': [],
|
||||
'Organize & Manage': [],
|
||||
'Optimize & Repair': [],
|
||||
'Secure PDF': [],
|
||||
Output: [],
|
||||
};
|
||||
|
||||
for (const entry of Object.values(nodeRegistry)) {
|
||||
if (entry.hidden) continue;
|
||||
result[entry.category].push(entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument, PDFName } from 'pdf-lib';
|
||||
|
||||
export class RemoveAnnotationsNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-eraser';
|
||||
readonly description = 'Strip all annotations';
|
||||
|
||||
constructor() {
|
||||
super('Remove Annotations');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Clean PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Remove Annotations');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfDoc = await PDFDocument.load(input.bytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const page of pages) {
|
||||
const annots = page.node.Annots();
|
||||
if (annots) {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_clean.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-file-minus';
|
||||
readonly description = 'Remove blank pages automatically';
|
||||
|
||||
constructor() {
|
||||
super('Remove Blank Pages');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'threshold',
|
||||
new ClassicPreset.InputControl('number', { initial: 250 })
|
||||
);
|
||||
}
|
||||
|
||||
private async isPageBlank(
|
||||
page: pdfjsLib.PDFPageProxy,
|
||||
threshold: number
|
||||
): Promise<boolean> {
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
let totalBrightness = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
totalBrightness += (data[i] + data[i + 1] + data[i + 2]) / 3;
|
||||
}
|
||||
const avgBrightness = totalBrightness / (data.length / 4);
|
||||
return avgBrightness > threshold;
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Remove Blank Pages');
|
||||
|
||||
const threshCtrl = this.controls['threshold'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const threshold = Math.max(200, Math.min(255, threshCtrl?.value ?? 250));
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: input.bytes })
|
||||
.promise;
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const nonBlankIndices: number[] = [];
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const blank = await this.isPageBlank(page, threshold);
|
||||
if (!blank) {
|
||||
nonBlankIndices.push(i - 1);
|
||||
} else {
|
||||
console.log(`Page ${i} detected as blank, removing`);
|
||||
}
|
||||
}
|
||||
|
||||
if (nonBlankIndices.length === 0) {
|
||||
throw new Error('All pages are blank');
|
||||
}
|
||||
|
||||
const newDoc = await PDFDocument.create();
|
||||
const copiedPages = await newDoc.copyPages(srcDoc, nonBlankIndices);
|
||||
copiedPages.forEach((page) => newDoc.addPage(page));
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_cleaned.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { initializeQpdf } from '../../utils/helpers.js';
|
||||
|
||||
export class RepairNode extends BaseWorkflowNode {
|
||||
readonly category = 'Optimize & Repair' as const;
|
||||
readonly icon = 'ph-wrench';
|
||||
readonly description = 'Repair corrupted PDF';
|
||||
|
||||
constructor() {
|
||||
super('Repair');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Repaired PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Repair');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const qpdf = await initializeQpdf();
|
||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const inputPath = `/tmp/input_repair_${uid}.pdf`;
|
||||
const outputPath = `/tmp/output_repair_${uid}.pdf`;
|
||||
|
||||
let repairedData: Uint8Array;
|
||||
try {
|
||||
qpdf.FS.writeFile(inputPath, input.bytes);
|
||||
qpdf.callMain([inputPath, '--decrypt', outputPath]);
|
||||
|
||||
repairedData = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
} finally {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch {
|
||||
/* cleanup */
|
||||
}
|
||||
}
|
||||
|
||||
const resultBytes = new Uint8Array(repairedData);
|
||||
const resultDoc = await PDFDocument.load(resultBytes, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: resultDoc,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_repaired.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class ReversePagesNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-sort-descending';
|
||||
readonly description = 'Reverse page order';
|
||||
|
||||
constructor() {
|
||||
super('Reverse Pages');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Reversed PDF'));
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Reverse Pages');
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const pageCount = srcDoc.getPageCount();
|
||||
const newDoc = await PDFDocument.create();
|
||||
const reversedIndices = Array.from(
|
||||
{ length: pageCount },
|
||||
(_, i) => pageCount - 1 - i
|
||||
);
|
||||
const copiedPages = await newDoc.copyPages(srcDoc, reversedIndices);
|
||||
copiedPages.forEach((page) => newDoc.addPage(page));
|
||||
const pdfBytes = await newDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_reversed.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { rotatePdfUniform } from '../../utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class RotateNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-arrow-clockwise';
|
||||
readonly description = 'Rotate all pages';
|
||||
|
||||
constructor() {
|
||||
super('Rotate');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Rotated PDF'));
|
||||
this.addControl(
|
||||
'angle',
|
||||
new ClassicPreset.InputControl('text', { initial: '90' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Rotate');
|
||||
const angleControl = this.controls['angle'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const angle = parseInt(angleControl?.value ?? '90', 10) || 90;
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const resultBytes = await rotatePdfUniform(input.bytes, angle);
|
||||
const resultDoc = await PDFDocument.load(resultBytes);
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: resultDoc,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_rotated.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { sanitizePdf } from '../../utils/sanitize';
|
||||
|
||||
export class SanitizeNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
readonly icon = 'ph-broom';
|
||||
readonly description = 'Remove metadata, scripts, and hidden data';
|
||||
|
||||
constructor() {
|
||||
super('Sanitize');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Sanitized PDF'));
|
||||
this.addControl(
|
||||
'flattenForms',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeMetadata',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeAnnotations',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeJavascript',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeEmbeddedFiles',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeLayers',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeLinks',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeStructureTree',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeMarkInfo',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
this.addControl(
|
||||
'removeFonts',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Sanitize');
|
||||
|
||||
const getBool = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return (ctrl?.value ?? fallback) === 'true';
|
||||
};
|
||||
|
||||
const options = {
|
||||
flattenForms: getBool('flattenForms', 'true'),
|
||||
removeMetadata: getBool('removeMetadata', 'true'),
|
||||
removeAnnotations: getBool('removeAnnotations', 'true'),
|
||||
removeJavascript: getBool('removeJavascript', 'true'),
|
||||
removeEmbeddedFiles: getBool('removeEmbeddedFiles', 'true'),
|
||||
removeLayers: getBool('removeLayers', 'true'),
|
||||
removeLinks: getBool('removeLinks', 'true'),
|
||||
removeStructureTree: getBool('removeStructureTree', 'true'),
|
||||
removeMarkInfo: getBool('removeMarkInfo', 'true'),
|
||||
removeFonts: getBool('removeFonts', 'false'),
|
||||
};
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const result = await sanitizePdf(input.bytes, options);
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: result.pdfDoc,
|
||||
bytes: result.bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_sanitized.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { applyScannerEffect } from '../../utils/image-effects';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { ScanSettings } from '../../types/scanner-effect-type';
|
||||
|
||||
export class ScannerEffectNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-scan';
|
||||
readonly description = 'Apply scanner simulation effect';
|
||||
|
||||
constructor() {
|
||||
super('Scanner Effect');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Scanned PDF'));
|
||||
this.addControl(
|
||||
'grayscale',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
this.addControl(
|
||||
'border',
|
||||
new ClassicPreset.InputControl('text', { initial: 'false' })
|
||||
);
|
||||
this.addControl(
|
||||
'rotation',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'rotationVariance',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'brightness',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'contrast',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'blur',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'noise',
|
||||
new ClassicPreset.InputControl('number', { initial: 10 })
|
||||
);
|
||||
this.addControl(
|
||||
'yellowish',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'resolution',
|
||||
new ClassicPreset.InputControl('number', { initial: 150 })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Scanner Effect');
|
||||
|
||||
const getNum = (key: string, fallback: number) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
return ctrl?.value ?? fallback;
|
||||
};
|
||||
|
||||
const getBool = (key: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value === 'true';
|
||||
};
|
||||
|
||||
const settings: ScanSettings = {
|
||||
grayscale: getBool('grayscale'),
|
||||
border: getBool('border'),
|
||||
rotate: getNum('rotation', 0),
|
||||
rotateVariance: getNum('rotationVariance', 0),
|
||||
brightness: getNum('brightness', 0),
|
||||
contrast: getNum('contrast', 0),
|
||||
blur: getNum('blur', 0),
|
||||
noise: getNum('noise', 10),
|
||||
yellowish: getNum('yellowish', 0),
|
||||
resolution: getNum('resolution', 150),
|
||||
};
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
const pdfjsDoc = await pdfjsLib.getDocument({ data: input.bytes })
|
||||
.promise;
|
||||
const dpiScale = settings.resolution / 72;
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: dpiScale });
|
||||
|
||||
const renderCanvas = document.createElement('canvas');
|
||||
renderCanvas.width = viewport.width;
|
||||
renderCanvas.height = viewport.height;
|
||||
const renderCtx = renderCanvas.getContext('2d');
|
||||
if (!renderCtx)
|
||||
throw new Error(`Failed to get canvas context for page ${i}`);
|
||||
await page.render({
|
||||
canvasContext: renderCtx,
|
||||
viewport,
|
||||
canvas: renderCanvas,
|
||||
}).promise;
|
||||
|
||||
const baseData = renderCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
renderCanvas.width,
|
||||
renderCanvas.height
|
||||
);
|
||||
const baselineCopy = new ImageData(
|
||||
new Uint8ClampedArray(baseData.data),
|
||||
baseData.width,
|
||||
baseData.height
|
||||
);
|
||||
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
applyScannerEffect(baselineCopy, outputCanvas, settings, 0, dpiScale);
|
||||
|
||||
const jpegBlob = await new Promise<Blob | null>((resolve) =>
|
||||
outputCanvas.toBlob(resolve, 'image/jpeg', 0.85)
|
||||
);
|
||||
|
||||
if (!jpegBlob) throw new Error(`Failed to render page ${i} to image`);
|
||||
|
||||
const jpegBytes = await jpegBlob.arrayBuffer();
|
||||
const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
|
||||
const newPage = newPdfDoc.addPage([
|
||||
outputCanvas.width,
|
||||
outputCanvas.height,
|
||||
]);
|
||||
newPage.drawImage(jpegImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: outputCanvas.width,
|
||||
height: outputCanvas.height,
|
||||
});
|
||||
}
|
||||
|
||||
if (newPdfDoc.getPageCount() === 0)
|
||||
throw new Error('No pages were processed');
|
||||
const pdfBytes = await newPdfDoc.save();
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: newPdfDoc,
|
||||
bytes: new Uint8Array(pdfBytes),
|
||||
filename: input.filename.replace(/\.pdf$/i, '_scanned.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { splitPdf, parsePageRange } from '../../utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class SplitNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-scissors';
|
||||
readonly description = 'Extract a range of pages';
|
||||
|
||||
constructor() {
|
||||
super('Split PDF');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'Split PDF'));
|
||||
this.addControl(
|
||||
'pages',
|
||||
new ClassicPreset.InputControl('text', { initial: '1-3' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Split PDF');
|
||||
const pagesControl = this.controls['pages'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const rangeStr = pagesControl?.value || '1';
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const srcDoc = await PDFDocument.load(input.bytes);
|
||||
const totalPages = srcDoc.getPageCount();
|
||||
const indices = parsePageRange(rangeStr, totalPages);
|
||||
const resultBytes = await splitPdf(input.bytes, indices);
|
||||
const resultDoc = await PDFDocument.load(resultBytes);
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: resultDoc,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_split.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export class SvgToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-file-svg';
|
||||
readonly description = 'Upload SVG files and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('SVG Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.endsWith('.svg') || file.type === 'image/svg+xml') {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} SVG files`;
|
||||
}
|
||||
|
||||
private async svgToPng(svgText: string): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const svgBlob = new Blob([svgText], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || 800;
|
||||
canvas.height = img.naturalHeight || 600;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to convert SVG'));
|
||||
return;
|
||||
}
|
||||
resolve(new Uint8Array(await blob.arrayBuffer()));
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load SVG'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No SVG files uploaded in SVG Input node');
|
||||
|
||||
const doc = await PDFDocument.create();
|
||||
|
||||
for (const file of this.files) {
|
||||
const svgText = await file.text();
|
||||
const pngBytes = await this.svgToPng(svgText);
|
||||
const pngImage = await doc.embedPng(pngBytes);
|
||||
const page = doc.addPage([pngImage.width, pngImage.height]);
|
||||
page.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pngImage.width,
|
||||
height: pngImage.height,
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = new Uint8Array(await doc.save());
|
||||
const result: PDFData = {
|
||||
type: 'pdf',
|
||||
document: doc,
|
||||
bytes: pdfBytes,
|
||||
filename: 'svg_converted.pdf',
|
||||
};
|
||||
|
||||
return { pdf: result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { WasmProvider } from '../../utils/wasm-provider.js';
|
||||
|
||||
export class TableOfContentsNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-list';
|
||||
readonly description = 'Generate table of contents from bookmarks';
|
||||
|
||||
constructor() {
|
||||
super('Table of Contents');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF with TOC'));
|
||||
this.addControl(
|
||||
'title',
|
||||
new ClassicPreset.InputControl('text', { initial: 'Table of Contents' })
|
||||
);
|
||||
this.addControl(
|
||||
'fontSize',
|
||||
new ClassicPreset.InputControl('number', { initial: 12 })
|
||||
);
|
||||
this.addControl(
|
||||
'fontFamily',
|
||||
new ClassicPreset.InputControl('number', { initial: 0 })
|
||||
);
|
||||
this.addControl(
|
||||
'addBookmark',
|
||||
new ClassicPreset.InputControl('text', { initial: 'true' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Table of Contents');
|
||||
|
||||
const titleCtrl = this.controls['title'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const fontSizeCtrl = this.controls['fontSize'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const fontFamilyCtrl = this.controls['fontFamily'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const addBookmarkCtrl = this.controls['addBookmark'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
|
||||
const title = titleCtrl?.value ?? 'Table of Contents';
|
||||
const fontSize = fontSizeCtrl?.value ?? 12;
|
||||
const fontFamily = fontFamilyCtrl?.value ?? 0;
|
||||
const addBookmark = (addBookmarkCtrl?.value ?? 'true') === 'true';
|
||||
|
||||
const cpdfUrl = WasmProvider.getUrl('cpdf');
|
||||
if (!cpdfUrl)
|
||||
throw new Error(
|
||||
'CoherentPDF is not configured. Please configure it in Advanced Settings.'
|
||||
);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const resultBytes = await new Promise<Uint8Array>((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
import.meta.env.BASE_URL + 'workers/table-of-contents.worker.js'
|
||||
);
|
||||
|
||||
worker.onmessage = (e: MessageEvent) => {
|
||||
worker.terminate();
|
||||
if (e.data.status === 'success') {
|
||||
resolve(new Uint8Array(e.data.pdfBytes));
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
e.data.message || 'Failed to generate table of contents'
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (err) => {
|
||||
worker.terminate();
|
||||
reject(new Error('Worker error: ' + err.message));
|
||||
};
|
||||
|
||||
const arrayBuffer = input.bytes.buffer.slice(
|
||||
input.bytes.byteOffset,
|
||||
input.bytes.byteOffset + input.bytes.byteLength
|
||||
);
|
||||
|
||||
worker.postMessage(
|
||||
{
|
||||
command: 'generate-toc',
|
||||
pdfData: arrayBuffer,
|
||||
title,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
addBookmark,
|
||||
cpdfUrl: cpdfUrl + 'coherentpdf.browser.min.js',
|
||||
},
|
||||
[arrayBuffer]
|
||||
);
|
||||
});
|
||||
|
||||
const bytes = new Uint8Array(resultBytes);
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_toc.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class TextToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-text-t';
|
||||
readonly description = 'Upload text file and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('Text Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'fontSize',
|
||||
new ClassicPreset.InputControl('number', { initial: 12 })
|
||||
);
|
||||
this.addControl(
|
||||
'fontFamily',
|
||||
new ClassicPreset.InputControl('text', { initial: 'helv' })
|
||||
);
|
||||
this.addControl(
|
||||
'fontColor',
|
||||
new ClassicPreset.InputControl('text', { initial: '#000000' })
|
||||
);
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.endsWith('.txt') || file.type === 'text/plain') {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} text files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No text files uploaded in Text Input node');
|
||||
|
||||
const fontSizeCtrl = this.controls['fontSize'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const fontSize = fontSizeCtrl?.value ?? 12;
|
||||
const fontFamilyCtrl = this.controls['fontFamily'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const fontName = (fontFamilyCtrl?.value ?? 'helv') as
|
||||
| 'helv'
|
||||
| 'tiro'
|
||||
| 'cour'
|
||||
| 'times';
|
||||
const fontColorCtrl = this.controls['fontColor'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const textColor = fontColorCtrl?.value ?? '#000000';
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const textContent = await file.text();
|
||||
const pdfBlob = await pymupdf.textToPdf(textContent, {
|
||||
fontSize,
|
||||
fontName,
|
||||
textColor,
|
||||
pageSize: 'a4',
|
||||
});
|
||||
const bytes = new Uint8Array(await pdfBlob.arrayBuffer());
|
||||
const pdfDoc = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.[^.]+$/, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class VsdToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-git-branch';
|
||||
readonly description = 'Upload Visio diagrams and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('VSD Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith('.vsd') || name.endsWith('.vsdx')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} Visio files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No Visio files uploaded in VSD Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.(vsd|vsdx)$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { addTextWatermark } from '../../utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
|
||||
export class WatermarkNode extends BaseWorkflowNode {
|
||||
readonly category = 'Edit & Annotate' as const;
|
||||
readonly icon = 'ph-drop';
|
||||
readonly description = 'Add text watermark';
|
||||
|
||||
constructor() {
|
||||
super('Watermark');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Watermarked PDF')
|
||||
);
|
||||
this.addControl(
|
||||
'text',
|
||||
new ClassicPreset.InputControl('text', { initial: 'DRAFT' })
|
||||
);
|
||||
this.addControl(
|
||||
'fontSize',
|
||||
new ClassicPreset.InputControl('number', { initial: 72 })
|
||||
);
|
||||
this.addControl(
|
||||
'color',
|
||||
new ClassicPreset.InputControl('text', { initial: '#808080' })
|
||||
);
|
||||
this.addControl(
|
||||
'opacity',
|
||||
new ClassicPreset.InputControl('number', { initial: 30 })
|
||||
);
|
||||
this.addControl(
|
||||
'angle',
|
||||
new ClassicPreset.InputControl('number', { initial: -45 })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Watermark');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return ctrl?.value || fallback;
|
||||
};
|
||||
const getNum = (key: string, fallback: number) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
return ctrl?.value ?? fallback;
|
||||
};
|
||||
|
||||
const colorHex = getText('color', '#808080');
|
||||
const c = hexToRgb(colorHex);
|
||||
|
||||
const watermarkText = getText('text', 'DRAFT');
|
||||
const fontSize = getNum('fontSize', 72);
|
||||
const opacity = getNum('opacity', 30) / 100;
|
||||
const angle = getNum('angle', -45);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const resultBytes = await addTextWatermark(input.bytes, {
|
||||
text: watermarkText,
|
||||
fontSize,
|
||||
color: { r: c.r, g: c.g, b: c.b },
|
||||
opacity,
|
||||
angle,
|
||||
});
|
||||
|
||||
const resultDoc = await PDFDocument.load(resultBytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: resultDoc,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_watermarked.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class WordToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-microsoft-word-logo';
|
||||
readonly description = 'Upload Word document and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('Word Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const ext = file.name.toLowerCase();
|
||||
if (
|
||||
ext.endsWith('.doc') ||
|
||||
ext.endsWith('.docx') ||
|
||||
ext.endsWith('.odt') ||
|
||||
ext.endsWith('.rtf')
|
||||
) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} documents`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No documents uploaded in Word Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.[^.]+$/, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class WpdToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-file-text';
|
||||
readonly description = 'Upload WordPerfect documents and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('WPD Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.wpd')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} WPD files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No WPD files uploaded in WPD Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.wpd$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { getLibreOfficeConverter } from '../../utils/libreoffice-loader.js';
|
||||
|
||||
export class WpsToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-file-text';
|
||||
readonly description = 'Upload WPS Office documents and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('WPS Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.wps')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} WPS files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No WPS files uploaded in WPS Input node');
|
||||
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const resultBlob = await converter.convertToPdf(file);
|
||||
const bytes = new Uint8Array(await resultBlob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.wps$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class XmlToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-file-code';
|
||||
readonly description = 'Upload XML files and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('XML Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
if (file.name.toLowerCase().endsWith('.xml')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} XML files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No XML files uploaded in XML Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const textContent = await file.text();
|
||||
const pdfBlob = await pymupdf.textToPdf(textContent, {
|
||||
fontSize: 10,
|
||||
fontName: 'cour',
|
||||
pageSize: 'a4',
|
||||
});
|
||||
const bytes = new Uint8Array(await pdfBlob.arrayBuffer());
|
||||
const pdfDoc = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document: pdfDoc,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.xml$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
|
||||
|
||||
export class XpsToPdfNode extends BaseWorkflowNode {
|
||||
readonly category = 'Input' as const;
|
||||
readonly icon = 'ph-scan';
|
||||
readonly description = 'Upload XPS/OXPS documents and convert to PDF';
|
||||
|
||||
private files: File[] = [];
|
||||
|
||||
constructor() {
|
||||
super('XPS Input');
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
}
|
||||
|
||||
async addFiles(fileList: File[]): Promise<void> {
|
||||
for (const file of fileList) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith('.xps') || name.endsWith('.oxps')) {
|
||||
this.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(index: number): void {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
hasFile(): boolean {
|
||||
return this.files.length > 0;
|
||||
}
|
||||
getFileCount(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
getFilenames(): string[] {
|
||||
return this.files.map((f) => f.name);
|
||||
}
|
||||
getFilename(): string {
|
||||
if (this.files.length === 0) return '';
|
||||
if (this.files.length === 1) return this.files[0].name;
|
||||
return `${this.files.length} XPS files`;
|
||||
}
|
||||
|
||||
async data(
|
||||
_inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
if (this.files.length === 0)
|
||||
throw new Error('No XPS files uploaded in XPS Input node');
|
||||
|
||||
const pymupdf = await loadPyMuPDF();
|
||||
const results: PDFData[] = [];
|
||||
for (const file of this.files) {
|
||||
const blob = await pymupdf.convertToPdf(file, { filetype: 'xps' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const document = await PDFDocument.load(bytes);
|
||||
results.push({
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: file.name.replace(/\.(xps|oxps)$/i, '.pdf'),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 1) return { pdf: results[0] };
|
||||
return { pdf: { type: 'multi-pdf', items: results } as MultiPDFData };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { NodeEditor } from 'rete';
|
||||
import type { AreaPlugin } from 'rete-area-plugin';
|
||||
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
||||
import type { BaseWorkflowNode } from './nodes/base-node';
|
||||
import { createNodeByType } from './nodes/registry';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import type { SerializedWorkflow } from './types';
|
||||
import { WORKFLOW_VERSION } from './types';
|
||||
|
||||
type AreaExtra = LitArea2D<ClassicScheme>;
|
||||
|
||||
interface SerializedNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
controls: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SerializedConnection {
|
||||
id: string;
|
||||
source: string;
|
||||
sourceOutput: string;
|
||||
target: string;
|
||||
targetInput: string;
|
||||
}
|
||||
|
||||
function getNodeType(node: BaseWorkflowNode): string | null {
|
||||
const constructorName = node.constructor.name;
|
||||
return constructorName || null;
|
||||
}
|
||||
|
||||
function serializeWorkflow(
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||
): SerializedWorkflow {
|
||||
const nodes: SerializedNode[] = [];
|
||||
const connections: SerializedConnection[] = [];
|
||||
|
||||
for (const node of editor.getNodes()) {
|
||||
const view = area.nodeViews.get(node.id);
|
||||
const position = view
|
||||
? { x: view.position.x, y: view.position.y }
|
||||
: { x: 0, y: 0 };
|
||||
|
||||
const controls: Record<string, unknown> = {};
|
||||
for (const [key, control] of Object.entries(node.controls)) {
|
||||
if (control && 'value' in control) {
|
||||
controls[key] = (control as { value: unknown }).value;
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: getNodeType(node as BaseWorkflowNode) || 'unknown',
|
||||
position,
|
||||
controls,
|
||||
});
|
||||
}
|
||||
|
||||
for (const conn of editor.getConnections()) {
|
||||
connections.push({
|
||||
id: conn.id,
|
||||
source: conn.source,
|
||||
sourceOutput: conn.sourceOutput as string,
|
||||
target: conn.target,
|
||||
targetInput: conn.targetInput as string,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: WORKFLOW_VERSION,
|
||||
nodes,
|
||||
connections,
|
||||
} as SerializedWorkflow;
|
||||
}
|
||||
|
||||
async function deserializeWorkflow(
|
||||
data: SerializedWorkflow,
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||
): Promise<void> {
|
||||
for (const conn of editor.getConnections()) {
|
||||
await editor.removeConnection(conn.id);
|
||||
}
|
||||
for (const node of editor.getNodes()) {
|
||||
await editor.removeNode(node.id);
|
||||
}
|
||||
|
||||
const idMap = new Map<string, string>();
|
||||
const skippedTypes: string[] = [];
|
||||
|
||||
for (const serializedNode of (data as any).nodes) {
|
||||
const node = createNodeByType(serializedNode.type);
|
||||
if (!node) {
|
||||
skippedTypes.push(serializedNode.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(serializedNode.controls || {})) {
|
||||
const control = node.controls[key];
|
||||
if (control && 'value' in control) {
|
||||
(control as any).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
await editor.addNode(node as any);
|
||||
idMap.set(serializedNode.id, node.id);
|
||||
|
||||
await area.translate(node.id, serializedNode.position);
|
||||
}
|
||||
|
||||
for (const serializedConn of (data as any).connections) {
|
||||
const sourceId = idMap.get(serializedConn.source);
|
||||
const targetId = idMap.get(serializedConn.target);
|
||||
if (!sourceId || !targetId) continue;
|
||||
|
||||
const sourceNode = editor.getNode(sourceId);
|
||||
const targetNode = editor.getNode(targetId);
|
||||
if (!sourceNode || !targetNode) continue;
|
||||
|
||||
const conn = new ClassicPreset.Connection(
|
||||
sourceNode,
|
||||
serializedConn.sourceOutput,
|
||||
targetNode,
|
||||
serializedConn.targetInput
|
||||
);
|
||||
await editor.addConnection(conn as any);
|
||||
}
|
||||
|
||||
if (skippedTypes.length > 0) {
|
||||
console.warn('Skipped unknown node types during load:', skippedTypes);
|
||||
throw new Error(
|
||||
`Some nodes could not be loaded: ${skippedTypes.join(', ')}. They may have been removed or renamed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const TEMPLATES_KEY = 'bento-pdf-workflow-templates';
|
||||
|
||||
interface StoredTemplates {
|
||||
[name: string]: SerializedWorkflow;
|
||||
}
|
||||
|
||||
function getStoredTemplates(): StoredTemplates {
|
||||
const json = localStorage.getItem(TEMPLATES_KEY);
|
||||
if (!json) return {};
|
||||
try {
|
||||
return JSON.parse(json) as StoredTemplates;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getSavedTemplateNames(): string[] {
|
||||
return Object.keys(getStoredTemplates());
|
||||
}
|
||||
|
||||
export function saveWorkflow(
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>,
|
||||
name: string
|
||||
): void {
|
||||
const data = serializeWorkflow(editor, area);
|
||||
const templates = getStoredTemplates();
|
||||
const backup = templates[name];
|
||||
templates[name] = data;
|
||||
try {
|
||||
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
||||
} catch (e) {
|
||||
if (backup !== undefined) {
|
||||
templates[name] = backup;
|
||||
} else {
|
||||
delete templates[name];
|
||||
}
|
||||
throw new Error(
|
||||
'Failed to save workflow: storage quota exceeded. Try deleting old templates.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function templateNameExists(name: string): boolean {
|
||||
const templates = getStoredTemplates();
|
||||
return name in templates;
|
||||
}
|
||||
|
||||
export async function loadWorkflow(
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>,
|
||||
name: string
|
||||
): Promise<boolean> {
|
||||
const templates = getStoredTemplates();
|
||||
const data = templates[name];
|
||||
if (!data) return false;
|
||||
|
||||
try {
|
||||
await deserializeWorkflow(data, editor, area);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to load workflow "${name}":`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteTemplate(name: string): void {
|
||||
const templates = getStoredTemplates();
|
||||
delete templates[name];
|
||||
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
||||
}
|
||||
|
||||
export function exportWorkflow(
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||
): void {
|
||||
const data = serializeWorkflow(editor, area);
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'workflow.json';
|
||||
a.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function importWorkflow(
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text) as SerializedWorkflow;
|
||||
await deserializeWorkflow(data, editor, area);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
reject(new Error(`Failed to import workflow: ${message}`));
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
|
||||
export class PDFSocket extends ClassicPreset.Socket {
|
||||
constructor() {
|
||||
super('PDF');
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageSocket extends ClassicPreset.Socket {
|
||||
constructor() {
|
||||
super('Image');
|
||||
}
|
||||
}
|
||||
|
||||
export class MultiPDFSocket extends ClassicPreset.Socket {
|
||||
constructor() {
|
||||
super('MultiPDF');
|
||||
}
|
||||
}
|
||||
|
||||
export const pdfSocket = new PDFSocket();
|
||||
export const imageSocket = new ImageSocket();
|
||||
export const multiPdfSocket = new MultiPDFSocket();
|
||||
|
||||
export const socketColors: Record<string, string> = {
|
||||
PDF: '#6366f1',
|
||||
Image: '#10b981',
|
||||
MultiPDF: '#f59e0b',
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export interface PDFData {
|
||||
type: 'pdf';
|
||||
document: PDFDocument;
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface ImageData {
|
||||
type: 'image';
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface MultiPDFData {
|
||||
type: 'multi-pdf';
|
||||
items: PDFData[];
|
||||
}
|
||||
|
||||
export type SocketData = PDFData | ImageData | MultiPDFData;
|
||||
|
||||
function clonePdf(pdf: PDFData): PDFData {
|
||||
return { ...pdf, bytes: pdf.bytes.slice() };
|
||||
}
|
||||
|
||||
export function extractSinglePdf(input: SocketData): PDFData {
|
||||
if (input.type === 'pdf') return clonePdf(input as PDFData);
|
||||
if (input.type === 'multi-pdf') {
|
||||
const items = (input as MultiPDFData).items;
|
||||
if (items.length === 0) throw new Error('No PDFs in input');
|
||||
return clonePdf(items[0]);
|
||||
}
|
||||
throw new Error('Expected PDF input');
|
||||
}
|
||||
|
||||
export function extractAllPdfs(inputs: SocketData[]): PDFData[] {
|
||||
const result: PDFData[] = [];
|
||||
for (const item of inputs) {
|
||||
if (item.type === 'pdf') result.push(clonePdf(item as PDFData));
|
||||
if (item.type === 'multi-pdf')
|
||||
result.push(...(item as MultiPDFData).items.map(clonePdf));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type NodeCategory =
|
||||
| 'Input'
|
||||
| 'Edit & Annotate'
|
||||
| 'Organize & Manage'
|
||||
| 'Optimize & Repair'
|
||||
| 'Secure PDF'
|
||||
| 'Output';
|
||||
|
||||
export interface NodeMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
category: NodeCategory;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SerializedWorkflow {
|
||||
version: number;
|
||||
nodes: SerializedNode[];
|
||||
connections: SerializedConnection[];
|
||||
}
|
||||
|
||||
export interface SerializedNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
controls: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SerializedConnection {
|
||||
id: string;
|
||||
source: string;
|
||||
sourceOutput: string;
|
||||
target: string;
|
||||
targetInput: string;
|
||||
}
|
||||
|
||||
export interface ExecutionProgress {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
progress?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const WORKFLOW_VERSION = 1;
|
||||
|
||||
export class WorkflowError extends Error {
|
||||
nodeName?: string;
|
||||
constructor(message: string, nodeName?: string) {
|
||||
super(nodeName ? `${nodeName}: ${message}` : message);
|
||||
this.name = 'WorkflowError';
|
||||
this.nodeName = nodeName;
|
||||
}
|
||||
}
|
||||
|
||||
export function requirePdfInput(
|
||||
inputs: Record<string, SocketData[]>,
|
||||
nodeName: string
|
||||
): SocketData[] {
|
||||
if (!inputs['pdf']?.[0])
|
||||
throw new WorkflowError('No PDF connected', nodeName);
|
||||
return inputs['pdf'];
|
||||
}
|
||||
|
||||
export async function processBatch(
|
||||
pdfInputs: SocketData[],
|
||||
fn: (pdf: PDFData) => Promise<PDFData>
|
||||
): Promise<SocketData> {
|
||||
const allPdfs = extractAllPdfs(pdfInputs);
|
||||
if (allPdfs.length === 0) throw new Error('No PDFs in input');
|
||||
if (allPdfs.length === 1) return fn(allPdfs[0]);
|
||||
const results: PDFData[] = [];
|
||||
for (const pdf of allPdfs) {
|
||||
results.push(await fn(pdf));
|
||||
}
|
||||
return { type: 'multi-pdf', items: results } as MultiPDFData;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>PDF Workflow Builder - Visual Pipeline | BentoPDF</title>
|
||||
<meta
|
||||
name="title"
|
||||
content="PDF Workflow Builder - Visual Pipeline | BentoPDF"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Build custom PDF processing pipelines visually. Drag, connect, and execute PDF tools in sequence. Free, secure, runs entirely in your browser."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="pdf workflow, pdf automation, pdf pipeline, visual pdf editor, node editor"
|
||||
/>
|
||||
<meta name="author" content="BentoPDF" />
|
||||
<meta
|
||||
name="robots"
|
||||
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
|
||||
/>
|
||||
|
||||
<link rel="canonical" href="https://www.bentopdf.com/pdf-workflow.html" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.bentopdf.com/pdf-workflow" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="PDF Workflow Builder - Visual Pipeline | BentoPDF"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Build custom PDF processing pipelines visually. Drag, connect, and execute PDF tools in sequence."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.bentopdf.com/images/og-pdf-workflow.png"
|
||||
/>
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:site_name" content="BentoPDF" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://www.bentopdf.com/pdf-workflow" />
|
||||
<meta name="twitter:title" content="PDF Workflow Builder" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Build custom PDF processing pipelines visually."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.bentopdf.com/images/twitter-pdf-workflow.png"
|
||||
/>
|
||||
<meta name="twitter:site" content="@BentoPDF" />
|
||||
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="PDF Workflow" />
|
||||
|
||||
<link href="/src/css/styles.css" rel="stylesheet" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/images/favicon-192x192.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="512x512"
|
||||
href="/images/favicon-512x512.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/images/apple-touch-icon.png"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
|
||||
<style>
|
||||
#rete-container {
|
||||
background-image: radial-gradient(circle, #374151 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
cursor: default;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#toolbox-sidebar {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#rete-container.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
#rete-container [data-testid='node'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#rete-container [data-testid='node']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.wf-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.wf-card {
|
||||
background: #1f2937;
|
||||
border: 2px solid var(--cat-color, #6b7280);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.wf-selected .wf-card {
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--cat-color) 40%, transparent);
|
||||
}
|
||||
|
||||
.wf-header {
|
||||
padding: 8px 14px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wf-cat {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--cat-color, #9ca3af);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.wf-divider {
|
||||
height: 1px;
|
||||
background: #374151;
|
||||
margin: 0 14px;
|
||||
}
|
||||
|
||||
.wf-body {
|
||||
padding: 10px 14px 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wf-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--cat-color, #9ca3af);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.wf-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f3f4f6;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wf-desc {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wf-tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--cat-color) 15%, transparent);
|
||||
color: var(--cat-color, #9ca3af);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.wf-sockets {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wf-sockets-top {
|
||||
margin-bottom: -7px;
|
||||
}
|
||||
|
||||
.wf-sockets-bottom {
|
||||
margin-top: -7px;
|
||||
}
|
||||
|
||||
.wf-socket-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wf-socket {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--socket-color, #6366f1);
|
||||
border: 2px solid #1f2937;
|
||||
box-shadow: 0 0 0 1px var(--socket-color, #6366f1);
|
||||
cursor: crosshair;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.wf-socket:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.connection .main-path {
|
||||
stroke: #6366f1;
|
||||
stroke-width: 2px;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
@keyframes wf-bar-slide {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wf-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-bar-slide {
|
||||
animation: wf-bar-slide 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wf-dot-pulse {
|
||||
animation: wf-dot-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="antialiased bg-gray-900 h-screen flex flex-col overflow-hidden">
|
||||
{{> navbar }}
|
||||
|
||||
<!-- Main Workflow Layout -->
|
||||
<div id="workflow-app" class="flex flex-1 min-h-0">
|
||||
<!-- LEFT SIDEBAR: Node Toolbox -->
|
||||
<aside
|
||||
id="toolbox-sidebar"
|
||||
class="w-60 bg-gray-800 border-r border-gray-700 flex flex-col overflow-y-auto flex-shrink-0"
|
||||
>
|
||||
<div class="p-3 border-b border-gray-700">
|
||||
<h2 class="text-white font-bold text-sm mb-2">Nodes</h2>
|
||||
<input
|
||||
id="node-search"
|
||||
type="text"
|
||||
placeholder="Search nodes..."
|
||||
class="w-full bg-gray-900 border border-gray-600 text-white rounded-md px-2 py-1.5 text-xs focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="toolbox-categories" class="flex-1 p-2 space-y-2 text-sm"></div>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER: Canvas + Toolbar -->
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Top Toolbar -->
|
||||
<div
|
||||
id="workflow-toolbar"
|
||||
class="h-11 bg-gray-800 border-b border-gray-700 flex items-center px-4 gap-2 flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
id="run-btn"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg flex items-center gap-1.5 text-sm transition-colors"
|
||||
>
|
||||
<i class="ph ph-play text-base"></i> Run
|
||||
</button>
|
||||
<button
|
||||
id="clear-btn"
|
||||
class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
id="save-btn"
|
||||
class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<i class="ph ph-floppy-disk text-sm"></i> Save
|
||||
</button>
|
||||
<button
|
||||
id="load-btn"
|
||||
class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<i class="ph ph-folder-open text-sm"></i> Load
|
||||
</button>
|
||||
<button
|
||||
id="export-btn"
|
||||
class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
id="import-btn"
|
||||
class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rete.js Canvas -->
|
||||
<div id="rete-container" class="flex-1 relative"></div>
|
||||
|
||||
<!-- Bottom Status Bar -->
|
||||
<div
|
||||
id="status-bar"
|
||||
class="h-7 bg-gray-800 border-t border-gray-700 flex items-center px-4 text-xs text-gray-400"
|
||||
>
|
||||
<span id="status-text">Ready</span>
|
||||
<div class="flex-1"></div>
|
||||
<span id="node-count">0 nodes</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- RIGHT SIDEBAR: Selected Node Settings -->
|
||||
<aside
|
||||
id="settings-sidebar"
|
||||
class="w-64 bg-gray-800 border-l border-gray-700 flex flex-col overflow-y-auto flex-shrink-0 hidden"
|
||||
>
|
||||
<div
|
||||
class="p-3 border-b border-gray-700 flex items-center justify-between"
|
||||
>
|
||||
<h2 id="settings-title" class="text-white font-bold text-sm">
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
id="close-settings"
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<i class="ph ph-x text-base"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="settings-content" class="flex-1 p-3 space-y-3"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Loader Modal -->
|
||||
<div
|
||||
id="loader-modal"
|
||||
class="hidden fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/95 p-8 rounded-2xl flex flex-col items-center gap-4 border border-gray-600/50 shadow-2xl"
|
||||
>
|
||||
<div class="solid-spinner"></div>
|
||||
<p id="loader-text" class="text-white text-lg font-medium">
|
||||
Processing...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Template Modal -->
|
||||
<div
|
||||
id="save-template-modal"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 hidden"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/95 rounded-2xl shadow-2xl p-6 max-w-sm w-full mx-4 border border-gray-600/50"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-indigo-500/10 flex items-center justify-center"
|
||||
>
|
||||
<i class="ph ph-floppy-disk text-lg text-indigo-400"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-white">Save Template</h3>
|
||||
</div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1.5"
|
||||
>Template Name</label
|
||||
>
|
||||
<input
|
||||
id="save-template-name"
|
||||
type="text"
|
||||
placeholder="e.g. Invoice Workflow"
|
||||
class="w-full bg-gray-900/80 border border-gray-600/60 text-white rounded-lg px-3 py-2.5 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 focus:outline-none placeholder-gray-500 mb-1"
|
||||
/>
|
||||
<p
|
||||
id="save-template-error"
|
||||
class="text-red-400 text-xs mb-4 hidden"
|
||||
></p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
id="save-template-cancel"
|
||||
class="flex-1 bg-gray-700/80 hover:bg-gray-600 text-gray-300 font-medium py-2.5 px-4 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
id="save-template-confirm"
|
||||
class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-medium py-2.5 px-4 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load Template Modal -->
|
||||
<div
|
||||
id="load-template-modal"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 hidden"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/95 rounded-2xl shadow-2xl p-6 max-w-md w-full mx-4 border border-gray-600/50"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-indigo-500/10 flex items-center justify-center"
|
||||
>
|
||||
<i class="ph ph-folder-open text-lg text-indigo-400"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-white">Load Template</h3>
|
||||
</div>
|
||||
<div
|
||||
id="load-template-list"
|
||||
class="space-y-1.5 max-h-64 overflow-y-auto mb-4"
|
||||
></div>
|
||||
<p
|
||||
id="load-template-empty"
|
||||
class="text-gray-500 text-sm text-center py-6 hidden"
|
||||
>
|
||||
No saved templates yet.
|
||||
</p>
|
||||
<button
|
||||
id="load-template-cancel"
|
||||
class="w-full bg-gray-700/80 hover:bg-gray-600 text-gray-300 font-medium py-2.5 px-4 rounded-lg transition-colors text-sm mt-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Modal -->
|
||||
<div
|
||||
id="alert-modal"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 hidden"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/95 rounded-2xl shadow-2xl p-6 max-w-sm w-full mx-4 border border-gray-600/50"
|
||||
>
|
||||
<h3 id="alert-title" class="text-lg font-semibold text-white mb-2">
|
||||
Alert
|
||||
</h3>
|
||||
<p id="alert-message" class="text-gray-300 text-sm mb-6"></p>
|
||||
<button
|
||||
id="alert-ok"
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-medium py-2.5 px-4 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import '@phosphor-icons/web/regular';
|
||||
</script>
|
||||
<script type="module" src="/src/version.ts"></script>
|
||||
<script type="module" src="/src/js/logic/pdf-workflow-page.ts"></script>
|
||||
<script type="module" src="/src/js/mobileMenu.ts"></script>
|
||||
<script type="module" src="/src/js/main.ts"></script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "PDF Workflow Builder - BentoPDF",
|
||||
"applicationCategory": "PDF Tool",
|
||||
"operatingSystem": "Any - Web Browser",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user