Add visual workflow builder, fix critical bugs, and add Arabic i18n support

This commit is contained in:
alam00000 committed 2026-02-08 17:05:40 +05:30
1 parent 36ebb3b429
commit 5d8b83e105
118 files changed
+14151 -2357

No files matched your search

+383
View File
@@ -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();
},
};
}
+164
View File
@@ -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'),
};
}),
};
}
}
+141
View File
@@ -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'),
};
}),
};
}
}
+30
View File
@@ -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,
};
}
}
+177
View File
@@ -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'),
};
}),
};
}
}
+70
View File
@@ -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'),
};
}),
};
}
}
+129
View File
@@ -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'),
};
}),
};
}
}
+79
View File
@@ -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'),
};
}),
};
}
}
+75
View File
@@ -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'),
};
}),
};
}
}
+65
View File
@@ -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'),
};
}),
};
}
}
+131
View File
@@ -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'),
};
}),
};
}
}
+70
View File
@@ -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'),
};
}),
};
}
}
+114
View File
@@ -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 };
}
}
+106
View File
@@ -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'),
};
}),
};
}
}
+69
View File
@@ -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 };
}
}
+69
View File
@@ -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 };
}
}
+45
View File
@@ -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'),
};
}),
};
}
}
+76
View File
@@ -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'),
};
}),
};
}
}
+172
View File
@@ -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'),
};
}),
};
}
}
+71
View File
@@ -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'),
};
}),
};
}
}
+80
View File
@@ -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 };
}
}
+64
View File
@@ -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 };
}
}
+40
View File
@@ -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',
},
};
}
}
+69
View File
@@ -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 };
}
}
+138
View File
@@ -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'),
};
}),
};
}
}
+84
View File
@@ -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'),
};
}),
};
}
}
+71
View File
@@ -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 };
}
}
+86
View File
@@ -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 };
}
}
+88
View File
@@ -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 {};
}
}
+51
View File
@@ -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 {};
}
}
+150
View File
@@ -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 {};
}
}
+82
View File
@@ -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'),
};
}),
};
}
}
+53
View File
@@ -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 {};
}
}
+50
View File
@@ -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 {};
}
}
+104
View File
@@ -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 {};
}
}
+116
View File
@@ -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 };
}
}
+71
View File
@@ -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 };
}
}
+79
View File
@@ -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'),
};
}),
};
}
}
+610
View File
@@ -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'),
};
}),
};
}
}
+66
View File
@@ -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'),
};
}),
};
}
}
+46
View File
@@ -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'),
};
}),
};
}
}
+96
View File
@@ -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'),
};
}),
};
}
}
+49
View File
@@ -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'),
};
}),
};
}
}
+104
View File
@@ -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'),
};
}),
};
}
}
+104
View File
@@ -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 };
}
}
+72
View File
@@ -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 };
}
}
+91
View File
@@ -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'),
};
}),
};
}
}
+77
View File
@@ -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 };
}
}
+71
View File
@@ -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 };
}
}
+71
View File
@@ -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 };
}
}
+74
View File
@@ -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 };
}
}
+70
View File
@@ -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 };
}
}
+255
View File
@@ -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();
});
}
+29
View File
@@ -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',
};
+124
View File
@@ -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;
}