Skip to content

Commit a7c77c1

Browse files
authored
Merge pull request #313 from SillyTyler/Tyler
feat(image): add batch watermark tool with multi-file support and tests #286
2 parents 95fb600 + af11b1e commit a7c77c1

6 files changed

Lines changed: 495 additions & 0 deletions

File tree

public/locales/en/image.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,38 @@
259259
"shortDescription": "Rotate an image easily.",
260260
"title": "Rotate Image"
261261
},
262+
"watermark": {
263+
"title": "Watermark Images",
264+
"description": "Add text watermarks to one or more images using each filename or custom text.",
265+
"shortDescription": "Add text watermarks to images",
266+
"inputTitle": "Input Images",
267+
"outputTitle": "Watermarked Images",
268+
"failedToProcess": "Failed to watermark one or more images. Please try again.",
269+
"options": {
270+
"watermarkTitle": "Source",
271+
"filenameWatermarkTitle": "Use Filename",
272+
"filenameWatermarkDesc": "Use each image's filename as the watermark.",
273+
"customStringWatermarkTitle": "Use Custom Watermark",
274+
"customStringWatermarkDesc": "Apply the same custom text to every image.",
275+
"styleTitle": "Style",
276+
"watermark": "Watermark text.",
277+
"fontSize": "Font size.",
278+
"opacity": "Watermark transparency.",
279+
"color": "Watermark color.",
280+
"positionTitle": "Position",
281+
"position": {
282+
"bottomLeft": "Bottom Left",
283+
"bottomRight": "Bottom Right",
284+
"topLeft": "Top Left",
285+
"topRight": "Top Right",
286+
"center": "Center"
287+
}
288+
},
289+
"toolInfo": {
290+
"title": "What is Watermark Images ?",
291+
"longDescription": "An image watermark tool lets you add customizable text watermarks to one or more images. You can automatically use each image's filename as the watermark or apply the same custom text to every image. Watermarks help protect your work, identify ownership, promote your brand, and organize image collections. Customize the font size, opacity, color, and position while preserving the original image quality."
292+
}
293+
},
262294
"split": {
263295
"title": "Split Image into Pages",
264296
"shortDescription": "Split a large image into multiple pages (PDF).",

src/pages/tools/image/generic/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { tool as imageToText } from './image-to-text/meta';
1010
import { tool as qrCodeGenerator } from './qr-code/meta';
1111
import { tool as rotateImage } from './rotate/meta';
1212
import { tool as imageEditor } from './editor/meta';
13+
import { tool as watermark } from './watermark/meta';
1314

1415
export const imageGenericTools = [
1516
imageEditor,
@@ -23,5 +24,6 @@ export const imageGenericTools = [
2324
imageToText,
2425
qrCodeGenerator,
2526
rotateImage,
27+
watermark,
2628
splitImage
2729
];
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { Box } from '@mui/material';
2+
import React, { useState, useContext } from 'react';
3+
import * as Yup from 'yup';
4+
import ToolMultipleImageInput, {
5+
MultiImageInput
6+
} from '@components/input/ToolMultipleImageInput';
7+
import ToolMultiFileResult from '@components/result/ToolMultiFileResult';
8+
import ColorSelector from '@components/options/ColorSelector';
9+
import ToolFileResult from '@components/result/ToolFileResult';
10+
import { GetGroupsType } from '@components/options/ToolOptions';
11+
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
12+
import ToolContent from '@components/ToolContent';
13+
import { ToolComponentProps } from '@tools/defineTool';
14+
import SimpleRadio from '@components/options/SimpleRadio';
15+
import { watermarkImages } from './service';
16+
import { InitialValuesType } from './types';
17+
import { CustomSnackBarContext } from 'contexts/CustomSnackBarContext';
18+
import { useTranslation } from 'react-i18next';
19+
import { getFileExtension } from '@utils/file';
20+
21+
const initialValues: InitialValuesType = {
22+
filename: true,
23+
watermark: 'OMNITOOLS',
24+
watermarkOpacity: 0.35,
25+
fontSize: 32,
26+
position: 'bottom-right',
27+
color: '#ffffff'
28+
};
29+
30+
const validationSchema = Yup.object({
31+
watermarkOpacity: Yup.number()
32+
.min(0, 'Opacity must be between 0 and 1')
33+
.max(1, 'Opacity must be between 0 and 1')
34+
.required('Opacity is required'),
35+
fontSize: Yup.number()
36+
.min(8, 'Font size must be at least 8px')
37+
.max(512, 'Font size is too large')
38+
.required('Font size is required'),
39+
color: Yup.string().required('Color is required')
40+
});
41+
42+
export default function Watermark({ title }: ToolComponentProps) {
43+
const { t } = useTranslation('image');
44+
const [input, setInput] = useState<MultiImageInput[]>([]);
45+
const [result, setResult] = useState<File[]>([]);
46+
const [zipFile, setZipFile] = useState<File | null>(null);
47+
const [isProcessing, setIsProcessing] = useState<boolean>(false);
48+
const { showSnackBar } = useContext(CustomSnackBarContext);
49+
50+
const compute = async (
51+
optionsValues: InitialValuesType,
52+
input: MultiImageInput[]
53+
) => {
54+
if (!input || input.length === 0) return;
55+
56+
try {
57+
setIsProcessing(true);
58+
59+
const output = await watermarkImages(
60+
input.map((img) => img.file),
61+
optionsValues
62+
);
63+
64+
if (!output) {
65+
showSnackBar(t('watermark.failedToProcess'), 'error');
66+
return;
67+
} else {
68+
setResult(output.results);
69+
setZipFile(output.zipFile);
70+
}
71+
} catch (error) {
72+
showSnackBar(`Error converting files: ${error}`, 'error');
73+
setZipFile(null);
74+
} finally {
75+
setIsProcessing(false);
76+
}
77+
};
78+
79+
const getGroups: GetGroupsType<InitialValuesType> = ({
80+
values,
81+
updateField
82+
}) => [
83+
{
84+
title: t('watermark.options.watermarkTitle'),
85+
component: (
86+
<Box>
87+
<Box mb={3}>
88+
<SimpleRadio
89+
onClick={() => updateField('filename', true)}
90+
checked={values.filename === true}
91+
title={t('watermark.options.filenameWatermarkTitle')}
92+
description={t('watermark.options.filenameWatermarkDesc')}
93+
/>
94+
<SimpleRadio
95+
onClick={() => updateField('filename', false)}
96+
checked={values.filename === false}
97+
title={t('watermark.options.customStringWatermarkTitle')}
98+
description={t('watermark.options.customStringWatermarkDesc')}
99+
/>
100+
</Box>
101+
{!values.filename && (
102+
<TextFieldWithDesc
103+
value={values.watermark}
104+
onOwnChange={(val) => updateField('watermark', val)}
105+
description={t('watermark.options.watermark')}
106+
inputProps={{
107+
'data-testid': 'watermark-input',
108+
type: 'string'
109+
}}
110+
/>
111+
)}
112+
</Box>
113+
)
114+
},
115+
{
116+
title: t('watermark.options.styleTitle'),
117+
component: (
118+
<Box>
119+
<TextFieldWithDesc
120+
value={String(values.watermarkOpacity)}
121+
onOwnChange={(val) => updateField('watermarkOpacity', Number(val))}
122+
description={t('watermark.options.opacity')}
123+
inputProps={{
124+
'data-testid': 'opacity-input',
125+
type: 'number',
126+
min: 0,
127+
max: 1,
128+
step: 0.05
129+
}}
130+
/>
131+
<TextFieldWithDesc
132+
value={String(values.fontSize)}
133+
onOwnChange={(val) => updateField('fontSize', Number(val))}
134+
description={t('watermark.options.fontSize')}
135+
inputProps={{
136+
'data-testid': 'font-size-input',
137+
type: 'number',
138+
min: 8,
139+
max: 512,
140+
step: 1
141+
}}
142+
/>
143+
<ColorSelector
144+
value={values.color}
145+
onColorChange={(val) => updateField('color', val)}
146+
description={t('watermark.options.color')}
147+
inputProps={{
148+
'data-testid': 'color-input'
149+
}}
150+
/>
151+
</Box>
152+
)
153+
},
154+
{
155+
title: t('watermark.options.positionTitle'),
156+
component: (
157+
<Box>
158+
<SimpleRadio
159+
onClick={() => updateField('position', 'bottom-right')}
160+
checked={values.position === 'bottom-right'}
161+
title={t('watermark.options.position.bottomRight')}
162+
/>
163+
<SimpleRadio
164+
onClick={() => updateField('position', 'bottom-left')}
165+
checked={values.position === 'bottom-left'}
166+
title={t('watermark.options.position.bottomLeft')}
167+
/>
168+
<SimpleRadio
169+
onClick={() => updateField('position', 'top-right')}
170+
checked={values.position === 'top-right'}
171+
title={t('watermark.options.position.topRight')}
172+
/>
173+
<SimpleRadio
174+
onClick={() => updateField('position', 'top-left')}
175+
checked={values.position === 'top-left'}
176+
title={t('watermark.options.position.topLeft')}
177+
/>
178+
<SimpleRadio
179+
onClick={() => updateField('position', 'center')}
180+
checked={values.position === 'center'}
181+
title={t('watermark.options.position.center')}
182+
/>
183+
</Box>
184+
)
185+
}
186+
];
187+
188+
return (
189+
<ToolContent
190+
title={title}
191+
initialValues={initialValues}
192+
getGroups={getGroups}
193+
compute={compute}
194+
input={input}
195+
validationSchema={validationSchema}
196+
inputComponent={
197+
<ToolMultipleImageInput
198+
value={input}
199+
type={'image'}
200+
onChange={setInput}
201+
accept={['image/*']}
202+
title={t('watermark.inputTitle')}
203+
/>
204+
}
205+
resultComponent={
206+
zipFile ? (
207+
<ToolMultiFileResult
208+
title={t('watermark.outputTitle')}
209+
value={result}
210+
zipFile={zipFile}
211+
loading={isProcessing}
212+
/>
213+
) : (
214+
<ToolFileResult
215+
title={t('watermark.outputTitle')}
216+
value={result[0] ?? null}
217+
extension={result[0] ? getFileExtension(result[0].name) : undefined}
218+
loading={isProcessing}
219+
/>
220+
)
221+
}
222+
toolInfo={{
223+
title: t('watermark.toolInfo.title'),
224+
description: t('watermark.toolInfo.longDescription')
225+
}}
226+
/>
227+
);
228+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { defineTool } from '@tools/defineTool';
2+
import { lazy } from 'react';
3+
4+
export const tool = defineTool('image-generic', {
5+
i18n: {
6+
name: 'image:watermark.title',
7+
description: 'image:watermark.description',
8+
shortDescription: 'image:watermark.shortDescription',
9+
userTypes: ['generalUsers']
10+
},
11+
12+
path: 'watermark',
13+
icon: 'mdi:watermark',
14+
15+
keywords: [
16+
'watermark',
17+
'image',
18+
'text',
19+
'filename',
20+
'overlay',
21+
'branding',
22+
'stamp'
23+
],
24+
25+
component: lazy(() => import('./index'))
26+
});

0 commit comments

Comments
 (0)