Merge branch 'main' into chesterkxng

This commit is contained in:
Chesterkxng
2025-05-24 02:57:17 +02:00
57 changed files with 6574 additions and 2493 deletions

View File

@@ -0,0 +1,12 @@
import ohmslaw from './ohmsLaw';
import voltageDropInWire from './voltageDropInWire';
import sphereArea from './sphereArea';
import sphereVolume from './sphereVolume';
import slackline from './slackline';
export default [
ohmslaw,
voltageDropInWire,
sphereArea,
sphereVolume,
slackline
];

View File

@@ -0,0 +1,46 @@
import type { GenericCalcType } from './types';
const ohmsLawCalc: GenericCalcType = {
icon: 'mdi:ohm',
keywords: [
'ohm',
'voltage',
'current',
'resistance',
'electrical',
'circuit',
'electronics',
'power',
'V=IR'
],
shortDescription:
"Calculate voltage, current, or resistance in electrical circuits using Ohm's Law",
name: "Ohm's Law",
path: 'ohms-law',
description: 'Calculates voltage, current and resistance',
longDescription:
"This calculator applies Ohm's Law (V = I × R) to determine any of the three electrical parameters when the other two are known. Ohm's Law is a fundamental principle in electrical engineering that describes the relationship between voltage (V), current (I), and resistance (R). This tool is essential for electronics hobbyists, electrical engineers, and students working with circuits to quickly solve for unknown values in their electrical designs.",
formula: 'V = I * R',
presets: [],
variables: [
{
name: 'V',
title: 'Voltage',
unit: 'volt',
default: 5
},
{
name: 'I',
title: 'Current',
unit: 'ampere',
default: 1
},
{
name: 'R',
title: 'Resistance',
unit: 'ohm'
}
]
};
export default ohmsLawCalc;

View File

@@ -0,0 +1,49 @@
import type { GenericCalcType } from './types';
const slackline: GenericCalcType = {
icon: 'mdi:bridge',
keywords: [
'mechanical',
'rope',
'webbing',
'cord',
'string',
'tension',
'clothesline'
],
shortDescription:
'Calculate the approximate tension of a slackline or clothesline. Do not rely on this for safety.',
name: 'Slackline Tension',
path: 'slackline-tension',
description: 'Calculates tension in a slackline',
longDescription: 'This calculator assumes a load in the center of the rope',
formula: 'T = (W * sqrt((S**2) + ((L/2)**2)) )/ (2S)',
presets: [],
variables: [
{
name: 'L',
title: 'Length',
unit: 'meter',
default: 2
},
{
name: 'W',
title: 'Weight',
unit: 'pound',
default: 1
},
{
name: 'S',
title: 'Sag/Deflection',
unit: 'meter',
default: 0.05
},
{
name: 'T',
title: 'Tension',
unit: 'pound-force'
}
]
};
export default slackline;

View File

@@ -0,0 +1,41 @@
import type { GenericCalcType } from './types';
const areaSphere: GenericCalcType = {
icon: 'ph:sphere-duotone',
keywords: [
'sphere',
'area',
'surface area',
'geometry',
'mathematics',
'radius',
'calculation',
'3D',
'shape'
],
shortDescription:
'Calculate the surface area of a sphere based on its radius',
name: 'Area of a Sphere',
path: 'area-sphere',
description: 'Area of a Sphere',
longDescription:
'This calculator determines the surface area of a sphere using the formula A = 4πr². You can either input the radius to find the surface area or enter the surface area to calculate the required radius. This tool is useful for students studying geometry, engineers working with spherical objects, and anyone needing to perform calculations involving spherical surfaces.',
formula: 'A = 4 * pi * r**2',
presets: [],
variables: [
{
name: 'A',
title: 'Area',
unit: 'mm2'
},
{
name: 'r',
title: 'Radius',
formula: 'r = sqrt(A/pi) / 2',
unit: 'mm',
default: 1
}
]
};
export default areaSphere;

View File

@@ -0,0 +1,47 @@
import type { GenericCalcType } from './types';
const volumeSphere: GenericCalcType = {
icon: 'gravity-ui:sphere',
keywords: [
'sphere',
'volume',
'geometry',
'mathematics',
'radius',
'diameter',
'calculation',
'3D',
'shape',
'capacity'
],
shortDescription: 'Calculate the volume of a sphere using radius or diameter',
name: 'Volume of a Sphere',
path: 'volume-sphere',
description: 'Volume of a Sphere',
longDescription:
'This calculator computes the volume of a sphere using the formula V = (4/3)πr³. You can input either the radius or diameter to find the volume, or enter the volume to determine the required radius. The tool is valuable for students, engineers, and professionals working with spherical objects in fields such as physics, engineering, and manufacturing.',
formula: 'v = (4/3) * pi * r**3',
presets: [],
variables: [
{
name: 'v',
title: 'Volume',
unit: 'mm3'
},
{
name: 'r',
title: 'Radius',
unit: 'mm',
default: 1,
alternates: [
{
title: 'Diameter',
formula: 'x = 2 * v',
unit: 'mm'
}
]
}
]
};
export default volumeSphere;

View File

@@ -0,0 +1,49 @@
import { DataTable } from '../../../../../datatables';
import { ToolMeta } from '@tools/defineTool';
export interface AlternativeVarInfo {
title: string;
unit: string;
defaultPrefix?: string;
formula: string;
}
export interface GenericCalcType extends Omit<ToolMeta, 'component'> {
formula: string;
extraOutputs?: {
title: string;
formula: string;
unit: string;
// Si prefix default
defaultPrefix?: string;
}[];
presets?: {
title: string;
source: DataTable;
default: string;
bind: {
[key: string]: string;
};
}[];
variables: {
name: string;
title: string;
unit: string;
defaultPrefix?: string;
// If absence, assume it's the default target var
default?: number;
// If present and false, don't allow user to select this as output
solvable?: boolean;
// Alternate rearrangement of the formula, to be used when calculating this.
// If missing, the main formula is used with auto derivation.
formula?: string;
// Alternates are alternate ways of entering the exact same thing,
// like the diameter or radius. The formula for an alternate
// can use only one variable, always called v, which is the main
// variable it's an alternate of
alternates?: AlternativeVarInfo[];
}[];
}

View File

@@ -0,0 +1,95 @@
import type { GenericCalcType } from './types';
import material_electrical_properties from '../../../../../datatables/data/material_electrical_properties';
import wire_gauge from '../../../../../datatables/data/wire_gauge';
const voltageDropInWire: GenericCalcType = {
icon: 'simple-icons:wire',
keywords: [
'voltage drop',
'cable',
'wire',
'electrical',
'resistance',
'power loss',
'conductor',
'resistivity',
'AWG',
'gauge'
],
shortDescription:
'Calculate voltage drop and power loss in electrical cables based on length, material, and current',
name: 'Round trip voltage drop in cable',
path: 'cable-voltage-drop',
formula: 'x = (((p * L) / (A/10**6) ) *2) * I',
description:
'Calculates round trip voltage and power loss in a 2 conductor cable',
longDescription:
'This calculator helps determine the voltage drop and power loss in a two-conductor electrical cable. It takes into account the cable length, wire gauge (cross-sectional area), material resistivity, and current flow. The tool calculates the round-trip voltage drop, total resistance of the cable, and the power dissipated as heat. This is particularly useful for electrical engineers, electricians, and hobbyists when designing electrical systems to ensure voltage levels remain within acceptable limits at the load.',
presets: [
{
title: 'Material',
source: material_electrical_properties,
default: 'Copper',
bind: {
p: 'resistivity_20c'
}
},
{
title: 'Wire Gauge',
source: wire_gauge,
default: '24 AWG',
bind: {
A: 'area'
}
}
],
extraOutputs: [
{
title: 'Total Resistance',
formula: '((p * L) / (A/10**6))*2',
unit: 'Ω'
},
{
title: 'Total Power Dissipated',
formula: 'I**2 * (((p * L) / (A/10**6))*2)',
unit: 'W'
}
],
variables: [
{
name: 'L',
title: 'Length',
unit: 'meter',
default: 1
},
{
name: 'A',
title: 'Wire Area',
unit: 'mm2',
default: 1
},
{
name: 'I',
title: 'Current',
unit: 'A',
default: 1
},
{
name: 'p',
title: 'Resistivity',
unit: 'Ω/m3',
default: 1,
defaultPrefix: 'n'
},
{
name: 'x',
title: 'Voltage Drop',
unit: 'V'
}
]
};
export default voltageDropInWire;

View File

@@ -0,0 +1,583 @@
import {
Autocomplete,
Box,
MenuItem,
Radio,
Select,
Stack,
TextField,
useTheme
} from '@mui/material';
import React, { useContext, useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import NumericInputWithUnit from '@components/input/NumericInputWithUnit';
import { UpdateField } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import type { AlternativeVarInfo, GenericCalcType } from './data/types';
import { dataTableLookup } from 'datatables';
import nerdamer from 'nerdamer-prime';
import 'nerdamer-prime/Algebra';
import 'nerdamer-prime/Solve';
import 'nerdamer-prime/Calculus';
import Qty from 'js-quantities';
import { CustomSnackBarContext } from 'contexts/CustomSnackBarContext';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import useMediaQuery from '@mui/material/useMediaQuery';
function numericSolveEquationFor(
equation: string,
varName: string,
variables: { [key: string]: number }
) {
let expr = nerdamer(equation);
for (const key in variables) {
expr = expr.sub(key, variables[key].toString());
}
let result: nerdamer.Expression | nerdamer.Expression[] =
expr.solveFor(varName);
// Sometimes the result is an array, check for it while keeping linter happy
if ((result as unknown as nerdamer.Expression).toDecimal === undefined) {
result = (result as unknown as nerdamer.Expression[])[0];
}
return parseFloat(
(result as unknown as nerdamer.Expression).evaluate().toDecimal()
);
}
export default async function makeTool(
calcData: GenericCalcType
): Promise<React.JSXElementConstructor<ToolComponentProps>> {
const initialValues: InitialValuesType = {
outputVariable: '',
vars: {},
presets: {}
};
return function GenericCalc({ title }: ToolComponentProps) {
const { showSnackBar } = useContext(CustomSnackBarContext);
const theme = useTheme();
const lessThanSmall = useMediaQuery(theme.breakpoints.down('sm'));
// For UX purposes we need to track what vars are
const [valsBoundToPreset, setValsBoundToPreset] = useState<{
[key: string]: string;
}>({});
const [extraOutputs, setExtraOutputs] = useState<{
[key: string]: number;
}>({});
const updateVarField = (
name: string,
value: number,
unit: string,
values: InitialValuesType,
updateFieldFunc: UpdateField<InitialValuesType>
) => {
// Make copy
const newVars = { ...values.vars };
newVars[name] = {
value,
unit: unit
};
updateFieldFunc('vars', newVars);
};
const handleSelectedTargetChange = (
varName: string,
updateFieldFunc: UpdateField<InitialValuesType>
) => {
updateFieldFunc('outputVariable', varName);
};
const handleSelectedPresetChange = (
selection: string,
preset: string,
currentValues: InitialValuesType,
updateFieldFunc: UpdateField<InitialValuesType>
) => {
const newPresets = { ...currentValues.presets };
newPresets[selection] = preset;
updateFieldFunc('presets', newPresets);
// Clear old selection using setState callback pattern
setValsBoundToPreset((prevState) => {
const newState = { ...prevState };
// Remove all keys bound to this selection
Object.keys(newState).forEach((key) => {
if (newState[key] === selection) {
delete newState[key];
}
});
return newState;
});
const selectionData = calcData.presets?.find(
(sel) => sel.title === selection
);
if (preset && preset != '<custom>') {
if (selectionData) {
// Create an object with the new bindings
const newBindings: { [key: string]: string } = {};
for (const key in selectionData.bind) {
// Add to newBindings for later state update
newBindings[key] = selection;
if (currentValues.outputVariable === key) {
handleSelectedTargetChange('', updateFieldFunc);
}
updateVarField(
key,
dataTableLookup(selectionData.source, preset)[
selectionData.bind[key]
],
selectionData.source.columns[selectionData.bind[key]]?.unit || '',
currentValues,
updateFieldFunc
);
}
// Update state with new bindings
setValsBoundToPreset((prevState) => ({
...prevState,
...newBindings
}));
} else {
throw new Error(
`Preset "${preset}" is not valid for selection "${selection}"`
);
}
}
};
calcData.variables.forEach((variable) => {
if (variable.solvable === undefined) {
variable.solvable = true;
}
if (variable.default === undefined) {
initialValues.vars[variable.name] = {
value: NaN,
unit: variable.unit
};
initialValues.outputVariable = variable.name;
} else {
initialValues.vars[variable.name] = {
value: variable.default || 0,
unit: variable.unit
};
}
});
calcData.presets?.forEach((selection) => {
initialValues.presets[selection.title] = selection.default;
if (selection.default == '<custom>') return;
for (const key in selection.bind) {
initialValues.vars[key] = {
value: dataTableLookup(selection.source, selection.default)[
selection.bind[key]
],
unit: selection.source.columns[selection.bind[key]]?.unit || ''
};
// We'll set this in useEffect instead of directly modifying state
}
});
function getAlternate(
alternateInfo: AlternativeVarInfo,
mainInfo: GenericCalcType['variables'][number],
mainValue: {
value: number;
unit: string;
}
) {
if (isNaN(mainValue.value)) return NaN;
const canonicalValue = Qty(mainValue.value, mainValue.unit).to(
mainInfo.unit
).scalar;
return numericSolveEquationFor(alternateInfo.formula, 'x', {
v: canonicalValue
});
}
function getMainFromAlternate(
alternateInfo: AlternativeVarInfo,
mainInfo: GenericCalcType['variables'][number],
alternateValue: {
value: number;
unit: string;
}
) {
if (isNaN(alternateValue.value)) return NaN;
const canonicalValue = Qty(alternateValue.value, alternateValue.unit).to(
alternateInfo.unit
).scalar;
return numericSolveEquationFor(alternateInfo.formula, 'v', {
x: canonicalValue
});
}
return (
<ToolContent
title={title}
inputComponent={null}
initialValues={initialValues}
toolInfo={{
title: calcData.name,
description: calcData.longDescription
}}
verticalGroups
getGroups={({ values, updateField }) => [
...(calcData.presets?.length
? [
{
title: 'Presets',
component: (
<Grid container spacing={2} maxWidth={500}>
{calcData.presets?.map((preset) => (
<Grid item xs={12} key={preset.title}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={'center'}
justifyContent={'space-between'}
>
<Typography>{preset.title}</Typography>
<Autocomplete
disablePortal
id="combo-box-demo"
value={values.presets[preset.title]}
options={[
'<custom>',
...Object.keys(preset.source.data).sort()
]}
sx={{ width: '80%' }}
onChange={(event, newValue) => {
handleSelectedPresetChange(
preset.title,
newValue || '',
values,
updateField
);
}}
renderInput={(params) => (
<TextField {...params} label="Preset" />
)}
/>
</Stack>
</Grid>
))}
</Grid>
)
}
]
: []),
{
title: 'Variables',
component: (
<Box>
{lessThanSmall ? (
<Stack
direction={'column'}
spacing={2}
alignItems={'center'}
justifyContent={'space-between'}
>
<Typography>Solve for</Typography>
<Select
sx={{ width: '80%' }}
fullWidth
value={values.outputVariable}
onChange={(event) =>
handleSelectedTargetChange(
event.target.value,
updateField
)
}
>
{calcData.variables.map((variable) => (
<MenuItem
disabled={
valsBoundToPreset[variable.name] !== undefined ||
variable.solvable === false
}
key={variable.name}
value={variable.name}
>
{variable.title}
</MenuItem>
))}
</Select>
</Stack>
) : (
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={10}></Grid>
<Grid item xs={2}>
<Typography fontWeight="bold" align="center">
Solve For
</Typography>
</Grid>
</Grid>
)}
{calcData.variables.map((variable) => (
<Box
key={variable.name}
sx={{
my: 3,
p: 1,
borderRadius: 1
}}
>
<Grid container spacing={2} alignItems="center">
<Grid item xs={lessThanSmall ? 12 : 10}>
<Box>
<Stack spacing={2}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems="center"
>
<Typography sx={{ minWidth: '8%' }}>
{variable.title}
</Typography>
<NumericInputWithUnit
defaultPrefix={variable.defaultPrefix}
value={values.vars[variable.name]}
disabled={
values.outputVariable === variable.name ||
valsBoundToPreset[variable.name] !== undefined
}
disableChangingUnit={
valsBoundToPreset[variable.name] !== undefined
}
onOwnChange={(val) =>
updateVarField(
variable.name,
val.value,
val.unit,
values,
updateField
)
}
/>
</Stack>
{variable.alternates?.map((alt) => (
<Box key={alt.title}>
<Stack
direction="row"
spacing={2}
alignItems="center"
>
<Typography sx={{ minWidth: '8%' }}>
{alt.title}
</Typography>
<Box sx={{ flexGrow: 1 }}>
<NumericInputWithUnit
key={alt.title}
defaultPrefix={alt.defaultPrefix || ''}
value={{
value:
getAlternate(
alt,
variable,
values.vars[variable.name]
) || NaN,
unit: alt.unit || ''
}}
disabled={
values.outputVariable ===
variable.name ||
valsBoundToPreset[variable.name] !==
undefined
}
disableChangingUnit={
valsBoundToPreset[variable.name] !==
undefined
}
onOwnChange={(val) =>
updateVarField(
variable.name,
getMainFromAlternate(
alt,
variable,
val
),
variable.unit,
values,
updateField
)
}
/>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Grid>
{!lessThanSmall && (
<Grid
item
xs={2}
sx={{ display: 'flex', justifyContent: 'center' }}
>
<Radio
value={variable.name}
checked={values.outputVariable === variable.name}
disabled={
valsBoundToPreset[variable.name] !== undefined ||
variable.solvable === false
}
onClick={() =>
handleSelectedTargetChange(
variable.name,
updateField
)
}
/>
</Grid>
)}
</Grid>
</Box>
))}
</Box>
)
},
...(calcData.extraOutputs
? [
{
title: 'Extra outputs',
component: (
<Box>
<Grid container spacing={2}>
{calcData.extraOutputs?.map((extraOutput) => (
<Grid item xs={12} key={extraOutput.title}>
<Stack spacing={1}>
<Typography>{extraOutput.title}</Typography>
<NumericInputWithUnit
disabled={true}
defaultPrefix={extraOutput.defaultPrefix}
value={{
value: extraOutputs[extraOutput.title],
unit: extraOutput.unit
}}
/>
</Stack>
</Grid>
))}
</Grid>
</Box>
)
}
]
: [])
]}
compute={(values) => {
if (values.outputVariable === '') {
showSnackBar('Please select a solve for variable', 'error');
return;
}
let expr: nerdamer.Expression | null = null;
for (const variable of calcData.variables) {
if (variable.name === values.outputVariable) {
if (variable.formula !== undefined) {
expr = nerdamer(variable.formula);
}
}
}
if (expr == null) {
expr = nerdamer(calcData.formula);
}
if (expr == null) {
throw new Error('No formula found');
}
Object.keys(values.vars).forEach((key) => {
if (key === values.outputVariable) return;
if (expr === null) {
throw new Error('Math fail');
}
expr = expr.sub(key, values.vars[key].value.toString());
});
let result: nerdamer.Expression | nerdamer.Expression[] =
expr.solveFor(values.outputVariable);
// Sometimes the result is an array
if (
(result as unknown as nerdamer.Expression).toDecimal === undefined
) {
if ((result as unknown as nerdamer.Expression[])?.length < 1) {
values.vars[values.outputVariable].value = NaN;
if (calcData.extraOutputs !== undefined) {
// Update extraOutputs using setState
setExtraOutputs((prevState) => {
const newState = { ...prevState };
for (let i = 0; i < calcData.extraOutputs!.length; i++) {
const extraOutput = calcData.extraOutputs![i];
newState[extraOutput.title] = NaN;
}
return newState;
});
}
throw new Error('No solution found for this input');
}
result = (result as unknown as nerdamer.Expression[])[0];
}
if (result) {
if (values.vars[values.outputVariable] != undefined) {
values.vars[values.outputVariable].value = parseFloat(
(result as unknown as nerdamer.Expression)
.evaluate()
.toDecimal()
);
}
} else {
values.vars[values.outputVariable].value = NaN;
}
if (calcData.extraOutputs !== undefined) {
for (let i = 0; i < calcData.extraOutputs.length; i++) {
const extraOutput = calcData.extraOutputs[i];
let expr = nerdamer(extraOutput.formula);
Object.keys(values.vars).forEach((key) => {
expr = expr.sub(key, values.vars[key].value.toString());
});
// todo could this have multiple solutions too?
const result: nerdamer.Expression = expr.evaluate();
if (result) {
// Update extraOutputs state properly
setExtraOutputs((prevState) => ({
...prevState,
[extraOutput.title]: parseFloat(result.toDecimal())
}));
}
}
}
}}
/>
);
};
}

View File

@@ -0,0 +1,28 @@
import { DefinedTool, defineTool } from '@tools/defineTool';
import { lazy } from 'react';
import type { GenericCalcType } from './data/types';
import allGenericCalcs from './data/index';
async function importComponent(data: GenericCalcType) {
const x = await import('./index');
return { default: await x.default(data) };
}
const tools: DefinedTool[] = [];
allGenericCalcs.forEach((x) => {
async function importComponent2() {
return await importComponent(x);
}
tools.push(
defineTool('number', {
...x,
path: 'generic-calc/' + x.path,
keywords: ['calculator', 'math', ...x.keywords],
component: lazy(importComponent2)
})
);
});
export { tools };

View File

@@ -0,0 +1,14 @@
export type InitialValuesType = {
vars: {
[key: string]: {
value: number;
unit: string;
};
};
// Track preset selections
presets: {
[key: string]: string;
};
outputVariable: string;
};

View File

@@ -1,5 +1,10 @@
import { tool as numberSum } from './sum/meta';
import { tool as numberGenerate } from './generate/meta';
import { tool as numberArithmeticSequence } from './arithmetic-sequence/meta';
export const numberTools = [numberSum, numberGenerate, numberArithmeticSequence];
import { tools as genericCalcTools } from './generic-calc/meta';
export const numberTools = [
numberSum,
numberGenerate,
numberArithmeticSequence,
...genericCalcTools
];

View File

@@ -1,12 +1,14 @@
import { tool as pdfRotatePdf } from './rotate-pdf/meta';
import { meta as splitPdfMeta } from './split-pdf/meta';
import { meta as mergePdf } from './merge-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
import { tool as compressPdfTool } from './compress-pdf/meta';
import { tool as protectPdfTool } from './protect-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
export const pdfTools: DefinedTool[] = [
splitPdfMeta,
pdfRotatePdf,
compressPdfTool,
protectPdfTool
protectPdfTool,
mergePdf
];

View File

@@ -0,0 +1,66 @@
import { useState } from 'react';
import ToolFileResult from '@components/result/ToolFileResult';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { mergePdf } from './service';
import ToolMultiPdfInput, {
MultiPdfInput
} from '@components/input/ToolMultiplePdfInput';
export default function MergePdf({ title }: ToolComponentProps) {
const [input, setInput] = useState<MultiPdfInput[]>([]);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = async (values: File[], input: MultiPdfInput[]) => {
if (input.length === 0) {
return;
}
try {
setIsProcessing(true);
const mergeResult = await mergePdf(input.map((i) => i.file));
setResult(mergeResult);
} catch (error) {
throw new Error('Error merging PDF:' + error);
} finally {
setIsProcessing(false);
}
};
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={input.map((i) => i.file)}
compute={compute}
inputComponent={
<ToolMultiPdfInput
value={input}
onChange={(pdfInputs) => {
setInput(pdfInputs);
}}
accept={['application/pdf']}
title={'Input PDF'}
type="pdf"
/>
}
getGroups={null}
resultComponent={
<ToolFileResult
title={'Output merged PDF'}
value={result}
extension={'pdf'}
loading={isProcessing}
loadingText={'Extracting pages'}
/>
}
toolInfo={{
title: 'How to Use the Merge PDF Tool?',
description: `This tool allows you to merge multiple PDF files into a single document.
To use the tool, simply upload the PDF files you want to merge. The tool will then combine all pages from the input files into a single PDF document.`
}}
/>
);
}

View File

@@ -0,0 +1,12 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const meta = defineTool('pdf', {
name: 'Merge PDF',
shortDescription: 'Merge multiple PDF files into a single document',
description: 'Combine multiple PDF files into a single document.',
icon: 'material-symbols-light:merge',
component: lazy(() => import('./index')),
keywords: ['pdf', 'merge', 'extract', 'pages', 'combine', 'document'],
path: 'merge-pdf'
});

View File

@@ -0,0 +1,43 @@
import { parsePageRanges } from './service';
describe('parsePageRanges', () => {
test('should return all pages when input is empty', () => {
expect(parsePageRanges('', 5)).toEqual([1, 2, 3, 4, 5]);
});
test('should parse single page numbers', () => {
expect(parsePageRanges('1,3,5', 5)).toEqual([1, 3, 5]);
});
test('should parse page ranges', () => {
expect(parsePageRanges('2-4', 5)).toEqual([2, 3, 4]);
});
test('should parse mixed page numbers and ranges', () => {
expect(parsePageRanges('1,3-5', 5)).toEqual([1, 3, 4, 5]);
});
test('should handle whitespace', () => {
expect(parsePageRanges(' 1, 3 - 5 ', 5)).toEqual([1, 3, 4, 5]);
});
test('should ignore invalid page numbers', () => {
expect(parsePageRanges('1,a,3', 5)).toEqual([1, 3]);
});
test('should ignore out-of-range page numbers', () => {
expect(parsePageRanges('1,6,3', 5)).toEqual([1, 3]);
});
test('should limit ranges to valid pages', () => {
expect(parsePageRanges('0-6', 5)).toEqual([1, 2, 3, 4, 5]);
});
test('should handle reversed ranges', () => {
expect(parsePageRanges('4-2', 5)).toEqual([2, 3, 4]);
});
test('should remove duplicates', () => {
expect(parsePageRanges('1,1,2,2-4,3', 5)).toEqual([1, 2, 3, 4]);
});
});

View File

@@ -0,0 +1,95 @@
import { PDFDocument } from 'pdf-lib';
/**
* Parses a page range string and returns an array of page numbers
* @param pageRangeStr String like "1,3-5,7" to extract pages 1, 3, 4, 5, and 7
* @param totalPages Total number of pages in the PDF
* @returns Array of page numbers to extract
*/
export function parsePageRanges(
pageRangeStr: string,
totalPages: number
): number[] {
if (!pageRangeStr.trim()) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pageNumbers = new Set<number>();
const ranges = pageRangeStr.split(',');
for (const range of ranges) {
const trimmedRange = range.trim();
if (trimmedRange.includes('-')) {
const [start, end] = trimmedRange.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
// Handle both forward and reversed ranges
const normalizedStart = Math.min(start, end);
const normalizedEnd = Math.max(start, end);
for (
let i = Math.max(1, normalizedStart);
i <= Math.min(totalPages, normalizedEnd);
i++
) {
pageNumbers.add(i);
}
}
} else {
const pageNum = parseInt(trimmedRange, 10);
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
pageNumbers.add(pageNum);
}
}
}
return [...pageNumbers].sort((a, b) => a - b);
}
/**
* Splits a PDF file based on specified page ranges
* @param pdfFile The input PDF file
* @param pageRanges String specifying which pages to extract (e.g., "1,3-5,7")
* @returns Promise resolving to a new PDF file with only the selected pages
*/
export async function splitPdf(
pdfFile: File,
pageRanges: string
): Promise<File> {
const arrayBuffer = await pdfFile.arrayBuffer();
const sourcePdf = await PDFDocument.load(arrayBuffer);
const totalPages = sourcePdf.getPageCount();
const pagesToExtract = parsePageRanges(pageRanges, totalPages);
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(
sourcePdf,
pagesToExtract.map((pageNum) => pageNum - 1)
);
copiedPages.forEach((page) => newPdf.addPage(page));
const newPdfBytes = await newPdf.save();
const newFileName = pdfFile.name.replace('.pdf', '-extracted.pdf');
return new File([newPdfBytes], newFileName, { type: 'application/pdf' });
}
/**
* Merges multiple PDF files into a single document
* @param pdfFiles Array of PDF files to merge
* @returns Promise resolving to a new PDF file with all pages combined
*/
export async function mergePdf(pdfFiles: File[]): Promise<File> {
const mergedPdf = await PDFDocument.create();
for (const pdfFile of pdfFiles) {
const arrayBuffer = await pdfFile.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
const mergedPdfBytes = await mergedPdf.save();
const mergedFileName = 'merged.pdf';
return new File([mergedPdfBytes], mergedFileName, {
type: 'application/pdf'
});
}

View File

@@ -37,7 +37,6 @@ export async function protectPdf(
password: options.password
};
const protectedFileUrl: string = await protectWithGhostScript(dataObject);
console.log('protected', protectedFileUrl);
return await loadPDFData(
protectedFileUrl,
pdfFile.name.replace('.pdf', '-protected.pdf')

View File

@@ -10,6 +10,7 @@ import { InitialValuesType, RotationAngle } from './types';
import { parsePageRanges, rotatePdf } from './service';
import SimpleRadio from '@components/options/SimpleRadio';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { isArray } from 'lodash';
const initialValues: InitialValuesType = {
rotationAngle: 90,

View File

@@ -1,5 +1,5 @@
import { Box, Typography } from '@mui/material';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';

View File

@@ -0,0 +1,169 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolVideoInput from '@components/input/ToolVideoInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
const initialValues: InitialValuesType = {
newSpeed: 2
};
export default function ChangeSpeed({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
// FFmpeg only supports a tempo between 0.5 and 2.0, so we chain filters
const computeAudioFilter = (speed: number): string => {
if (speed <= 2 && speed >= 0.5) {
return `atempo=${speed}`;
}
// Break into supported chunks
const filters: string[] = [];
let remainingSpeed = speed;
while (remainingSpeed > 2.0) {
filters.push('atempo=2.0');
remainingSpeed /= 2.0;
}
while (remainingSpeed < 0.5) {
filters.push('atempo=0.5');
remainingSpeed /= 0.5;
}
filters.push(`atempo=${remainingSpeed.toFixed(2)}`);
return filters.join(',');
};
const compute = (optionsValues: InitialValuesType, input: File | null) => {
if (!input) return;
const { newSpeed } = optionsValues;
let ffmpeg: FFmpeg | null = null;
let ffmpegLoaded = false;
const processVideo = async (
file: File,
newSpeed: number
): Promise<void> => {
if (newSpeed === 0) return;
setLoading(true);
if (!ffmpeg) {
ffmpeg = new FFmpeg();
}
if (!ffmpegLoaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
ffmpegLoaded = true;
}
// Write file to FFmpeg FS
const fileName = file.name;
const outputName = 'output.mp4';
try {
ffmpeg.writeFile(fileName, await fetchFile(file));
const videoFilter = `setpts=${1 / newSpeed}*PTS`;
const audioFilter = computeAudioFilter(newSpeed);
// Run FFmpeg command
await ffmpeg.exec([
'-i',
fileName,
'-vf',
videoFilter,
'-filter:a',
audioFilter,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-c:a',
'aac',
outputName
]);
const data = await ffmpeg.readFile(outputName);
// Create new file from processed data
const blob = new Blob([data], { type: 'video/mp4' });
const newFile = new File(
[blob],
file.name.replace('.mp4', `-${newSpeed}x.mp4`),
{ type: 'video/mp4' }
);
// Clean up to free memory
await ffmpeg.deleteFile(fileName);
await ffmpeg.deleteFile(outputName);
setResult(newFile);
} catch (err) {
console.error(`Failed to process video: ${err}`);
throw err;
} finally {
setLoading(false);
}
};
// Here we set the output video
processVideo(input, newSpeed);
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'New Video Speed',
component: (
<Box>
<TextFieldWithDesc
value={values.newSpeed.toString()}
onOwnChange={(val) => updateField('newSpeed', Number(val))}
description="Default multiplier: 2 means 2x faster"
type="number"
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Setting Speed" value={null} loading={true} />
) : (
<ToolFileResult title="Edited Video" value={result} extension="mp4" />
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Change speed',
path: 'change-speed',
icon: 'material-symbols-light:speed-outline',
description:
'This online utility lets you change the speed of a video. You can speed it up or slow it down.',
shortDescription: 'Quickly change video speed',
keywords: ['change', 'speed'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,8 @@
import { InitialValuesType } from './types';
export function main(
input: File | null,
options: InitialValuesType
): File | null {
return input;
}

View File

@@ -0,0 +1,3 @@
export type InitialValuesType = {
newSpeed: number;
};

View File

@@ -160,7 +160,6 @@ export default function CompressVideo({ title }: ToolComponentProps) {
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
/>
}

View File

@@ -0,0 +1,113 @@
import { Box } from '@mui/material';
import { useCallback, useState } from 'react';
import * as Yup from 'yup';
import ToolFileResult from '@components/result/ToolFileResult';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { debounce } from 'lodash';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { flipVideo } from './service';
import { FlipOrientation, InitialValuesType } from './types';
import SimpleRadio from '@components/options/SimpleRadio';
export const initialValues: InitialValuesType = {
orientation: 'horizontal'
};
export const validationSchema = Yup.object({
orientation: Yup.string()
.oneOf(
['horizontal', 'vertical'],
'Orientation must be horizontal or vertical'
)
.required('Orientation is required')
});
const orientationOptions: { value: FlipOrientation; label: string }[] = [
{ value: 'horizontal', label: 'Horizontal (Mirror)' },
{ value: 'vertical', label: 'Vertical (Upside Down)' }
];
export default function FlipVideo({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (
optionsValues: InitialValuesType,
input: File | null
) => {
if (!input) return;
setLoading(true);
try {
const flippedFile = await flipVideo(input, optionsValues.orientation);
setResult(flippedFile);
} catch (error) {
console.error('Error flipping video:', error);
} finally {
setLoading(false);
}
};
const debouncedCompute = useCallback(debounce(compute, 1000), []);
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Orientation',
component: (
<Box>
{orientationOptions.map((orientationOption) => (
<SimpleRadio
key={orientationOption.value}
title={orientationOption.label}
checked={values.orientation === orientationOption.value}
onClick={() => {
updateField('orientation', orientationOption.value);
}}
/>
))}
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolVideoInput
value={input}
onChange={setInput}
title={'Input Video'}
/>
}
resultComponent={
loading ? (
<ToolFileResult
title={'Flipping Video'}
value={null}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
title={'Flipped Video'}
value={result}
extension={'mp4'}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
compute={debouncedCompute}
setInput={setInput}
validationSchema={validationSchema}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Flip Video',
path: 'flip',
icon: 'mdi:flip-horizontal',
description:
'This online utility allows you to flip videos horizontally or vertically. You can preview the flipped video before processing. Supports common video formats like MP4, WebM, and OGG.',
shortDescription: 'Flip videos horizontally or vertically',
keywords: ['flip', 'video', 'mirror', 'edit', 'horizontal', 'vertical'],
longDescription:
'Easily flip your videos horizontally (mirror) or vertically (upside down) with this simple online tool.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,43 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { FlipOrientation } from './types';
const ffmpeg = new FFmpeg();
export async function flipVideo(
input: File,
orientation: FlipOrientation
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
const inputName = 'input.mp4';
const outputName = 'output.mp4';
await ffmpeg.writeFile(inputName, await fetchFile(input));
const flipMap: Record<FlipOrientation, string> = {
horizontal: 'hflip',
vertical: 'vflip'
};
const flipFilter = flipMap[orientation];
const args = ['-i', inputName];
if (flipFilter) {
args.push('-vf', flipFilter);
}
args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
await ffmpeg.exec(args);
const flippedData = await ffmpeg.readFile(outputName);
return new File(
[new Blob([flippedData], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_flipped.mp4`,
{ type: 'video/mp4' }
);
}

View File

@@ -0,0 +1,5 @@
export type FlipOrientation = 'horizontal' | 'vertical';
export type InitialValuesType = {
orientation: FlipOrientation;
};

View File

@@ -41,14 +41,27 @@ export default function ChangeSpeed({ title }: ToolComponentProps) {
try {
await ffmpeg.writeFile('input.gif', await fetchFile(file));
// Use FFmpeg's setpts filter to change the speed
// PTS (Presentation Time Stamp) determines when each frame is shown
// 1/speed changes the PTS - lower value = faster playback
// Process the GIF to change playback speed while preserving quality
// The filter_complex does three main operations:
// 1. [0:v]setpts=${1/newSpeed}*PTS - Adjusts frame timing:
// - PTS (Presentation Time Stamp) controls when each frame is displayed
// - Dividing by speed factor (e.g., 2 for 2x speed) reduces display time
// - Example: 1/2 = 0.5 → frames show for half their normal duration
// 2. split[a][b] - Creates two identical streams for parallel processing:
// - [a] goes to palettegen to create an optimized color palette
// - [b] contains the speed-adjusted frames
// 3. [b][p]paletteuse - Applies the generated palette to maintain:
// - Color accuracy
// - Transparency handling
// - Reduced file size
// This approach prevents visual artifacts that occur with simple re-encoding
await ffmpeg.exec([
'-i',
'input.gif',
'-filter:v',
`setpts=${1 / newSpeed}*PTS`,
'-filter_complex',
`[0:v]setpts=${
1 / newSpeed
}*PTS,split[a][b];[a]palettegen[p];[b][p]paletteuse`,
'-f',
'gif',
'output.gif'

View File

@@ -1,7 +1,20 @@
import { tool as videoChangeSpeed } from './change-speed/meta';
import { tool as videoFlip } from './flip/meta';
import { rotate } from '../string/rotate/service';
import { gifTools } from './gif';
import { tool as trimVideo } from './trim/meta';
import { tool as rotateVideo } from './rotate/meta';
import { tool as compressVideo } from './compress/meta';
import { tool as loopVideo } from './loop/meta';
import { tool as flipVideo } from './flip/meta';
import { tool as changeSpeed } from './change-speed/meta';
export const videoTools = [...gifTools, trimVideo, rotateVideo, compressVideo];
export const videoTools = [
...gifTools,
trimVideo,
rotateVideo,
compressVideo,
loopVideo,
flipVideo,
changeSpeed
];

View File

@@ -0,0 +1,89 @@
import { Box } from '@mui/material';
import { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { CardExampleType } from '@components/examples/ToolExamples';
import { loopVideo } from './service';
import { InitialValuesType } from './types';
import ToolVideoInput from '@components/input/ToolVideoInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { updateNumberField } from '@utils/string';
import * as Yup from 'yup';
const initialValues: InitialValuesType = {
loops: 2
};
const validationSchema = Yup.object({
loops: Yup.number().min(1, 'Number of loops must be greater than 1')
});
export default function Loop({ title, longDescription }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
try {
setLoading(true);
const resultFile = await loopVideo(input, values);
await setResult(resultFile);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'Loops',
component: (
<Box>
<TextFieldWithDesc
onOwnChange={(value) =>
updateNumberField(value, 'loops', updateField)
}
value={values.loops}
label={'Number of Loops'}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={<ToolVideoInput value={input} onChange={setInput} />}
resultComponent={
loading ? (
<ToolFileResult
value={null}
title={'Looping Video'}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
value={result}
title={'Looped Video'}
extension={'mp4'}
/>
)
}
initialValues={initialValues}
validationSchema={validationSchema}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Loop Video',
path: 'loop',
icon: 'ic:baseline-loop',
description:
'This online utility lets you loop videos by specifying the number of repetitions. You can preview the looped video before processing. Supports common video formats like MP4, WebM, and OGG.',
shortDescription: 'Loop videos multiple times',
keywords: ['loop', 'video', 'repeat', 'duplicate', 'sequence', 'playback'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,41 @@
import { InitialValuesType } from './types';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
const ffmpeg = new FFmpeg();
export async function loopVideo(
input: File,
options: InitialValuesType
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
const inputName = 'input.mp4';
const outputName = 'output.mp4';
await ffmpeg.writeFile(inputName, await fetchFile(input));
const args = [];
const loopCount = options.loops - 1;
if (loopCount <= 0) {
return input;
}
args.push('-stream_loop', loopCount.toString());
args.push('-i', inputName);
args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
await ffmpeg.exec(args);
const loopedData = await ffmpeg.readFile(outputName);
return await new File(
[new Blob([loopedData], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_looped.mp4`,
{ type: 'video/mp4' }
);
}

View File

@@ -0,0 +1,3 @@
export type InitialValuesType = {
loops: number;
};

View File

@@ -81,7 +81,6 @@ export default function RotateVideo({ title }: ToolComponentProps) {
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
/>
}

View File

@@ -116,7 +116,6 @@ export default function TrimVideo({ title }: ToolComponentProps) {
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
showTrimControls={true}
onTrimChange={(trimStart, trimEnd) => {