mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-11-07 09:24:55 +05:30
Merge f13130a9af into f3c5946e0d
This commit is contained in:
commit
6506c69828
9 changed files with 431 additions and 9 deletions
|
|
@ -101,5 +101,54 @@
|
|||
"description": "Rotate an image by a specified angle.",
|
||||
"shortDescription": "Rotate an image easily.",
|
||||
"title": "Rotate Image"
|
||||
},
|
||||
"split": {
|
||||
"title": "Split Image into Pages",
|
||||
"shortDescription": "Split a large image into multiple pages (PDF).",
|
||||
"description": "Split a map or large image into pages matching the selected paper format and padding. Specify pixels per printing square and square count to scale the image correctly.",
|
||||
"inputTitle": "Input image",
|
||||
"resultTitle": "Split PDF",
|
||||
"pageParameters": {
|
||||
"title": "Page parameters",
|
||||
"selectPageFormat": "Select page format",
|
||||
"padding": {
|
||||
"label": "Padding",
|
||||
"description": "Padding around the map on each page (in selected units)"
|
||||
}
|
||||
},
|
||||
"scaleParameters": {
|
||||
"title": "Scale parameters",
|
||||
"pxPerSquareQuantity": {
|
||||
"label": "Pixels per squares quantity",
|
||||
"description": "How many pixels correspond to the specified number of squares on the original image (horizontal or vertical)"
|
||||
},
|
||||
"squareQuantity": {
|
||||
"label": "Squares quantity",
|
||||
"description": "Number of play squares across (horizontal or vertical) used for scaling"
|
||||
},
|
||||
"unitsPerOneSquare": {
|
||||
"label": "Units per square",
|
||||
"description": "Physical size of one square in the selected units"
|
||||
}
|
||||
},
|
||||
"units": {
|
||||
"title": "Units",
|
||||
"select": "Select measurement units",
|
||||
"options": {
|
||||
"pt": "Points (pt)",
|
||||
"mm": "Millimeters (mm)",
|
||||
"in": "Inches (in)"
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
"imagePlaceholder": "Drop or select an image with a tactical map"
|
||||
},
|
||||
"pdfOptions": {
|
||||
"downloadLabel": "Download PDF"
|
||||
},
|
||||
"toolInfo": {
|
||||
"title": "About Split",
|
||||
"description": "This tool slices a large image into pages of the selected paper format and packs them into a PDF. Useful for printing maps across multiple sheets."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,5 +101,54 @@
|
|||
"description": "Повернуть изображение на указанный угол.",
|
||||
"shortDescription": "Легко поворачивайте изображение.",
|
||||
"title": "Повернуть изображение"
|
||||
},
|
||||
"split": {
|
||||
"title": "Разбить изображение по страницам",
|
||||
"shortDescription": "Разбивает большое изображение на несколько страниц (PDF).",
|
||||
"description": "Разбивает карту или большое изображение на страницы выбранного формата с учётом полей. Укажите количество пикселей на заданное число клеток и их количество для корректного масштабирования.",
|
||||
"inputTitle": "Входное изображение",
|
||||
"resultTitle": "PDF с разрезанными страницами",
|
||||
"pageParameters": {
|
||||
"title": "Параметры страницы",
|
||||
"selectPageFormat": "Выберите формат страницы",
|
||||
"padding": {
|
||||
"label": "Отступ",
|
||||
"description": "Отступ вокруг карты на каждой странице (в выбранных единицах)"
|
||||
}
|
||||
},
|
||||
"scaleParameters": {
|
||||
"title": "Параметры масштаба",
|
||||
"pxPerSquareQuantity": {
|
||||
"label": "Пикселей на количество клеток",
|
||||
"description": "Сколько пикселей соответствует указанному количеству клеток на исходном изображении (по горизонтали или вертикали)"
|
||||
},
|
||||
"squareQuantity": {
|
||||
"label": "Количество клеток",
|
||||
"description": "Число игровых клеток по горизонтали или вертикали, используемое для масштабирования"
|
||||
},
|
||||
"unitsPerOneSquare": {
|
||||
"label": "Размер клетки",
|
||||
"description": "Физический размер одной клетки в выбранных единицах"
|
||||
}
|
||||
},
|
||||
"units": {
|
||||
"title": "Единицы измерения",
|
||||
"select": "Выберите единицы измерения",
|
||||
"options": {
|
||||
"pt": "Пункты (pt)",
|
||||
"mm": "Миллиметры (мм)",
|
||||
"in": "Дюймы (in)"
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
"imagePlaceholder": "Перетащите или выберите изображение с тактической картой"
|
||||
},
|
||||
"pdfOptions": {
|
||||
"downloadLabel": "Скачать PDF"
|
||||
},
|
||||
"toolInfo": {
|
||||
"title": "О разрезе",
|
||||
"description": "Этот инструмент нарезает большое изображение на страницы выбранного формата и упаковывает их в PDF. Удобно для печати карт на нескольких листах."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { tool as splitImage } from './split/meta';
|
||||
import { tool as resizeImage } from './resize/meta';
|
||||
import { tool as compressImage } from './compress/meta';
|
||||
import { tool as changeColors } from './change-colors/meta';
|
||||
|
|
@ -22,5 +23,6 @@ export const imageGenericTools = [
|
|||
imageToText,
|
||||
qrCodeGenerator,
|
||||
rotateImage,
|
||||
convertToJpg
|
||||
convertToJpg,
|
||||
splitImage
|
||||
];
|
||||
|
|
|
|||
200
src/pages/tools/image/generic/split/index.tsx
Normal file
200
src/pages/tools/image/generic/split/index.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { Box } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { InitialValuesType } from './types';
|
||||
import ToolImageInput from '@components/input/ToolImageInput';
|
||||
import { t } from 'i18next';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { updateNumberField } from '@utils/string';
|
||||
import { fromPts, loadImage, splitImage, toPts } from './service';
|
||||
import { debounce } from 'lodash';
|
||||
import { PDFDocument, PageSizes } from 'pdf-lib';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
pageFormat: 'A4',
|
||||
pageWidth: 210,
|
||||
pageHeight: 297,
|
||||
pxPerSquareQuantity: 1620,
|
||||
squareQuantity: 20,
|
||||
unitsPerOneSquare: fromPts(72, 'mm'),
|
||||
unitKind: 'mm',
|
||||
padding: 5
|
||||
};
|
||||
|
||||
export default function Split({ title, longDescription }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
|
||||
const compute = async (values: InitialValuesType, input: File | null) => {
|
||||
if (input) {
|
||||
const initImg = await loadImage(input);
|
||||
|
||||
const [pageWidth, pageHeight] = PageSizes[values.pageFormat];
|
||||
const ptPerOneSquare = toPts(values.unitsPerOneSquare, values.unitKind);
|
||||
const pxPerPt =
|
||||
values.pxPerSquareQuantity / (ptPerOneSquare * values.squareQuantity);
|
||||
const paddingInPts = toPts(values.padding, values.unitKind);
|
||||
const pageWidthWithPadding = pageWidth - 2 * paddingInPts;
|
||||
const pageHeightWithPadding = pageHeight - 2 * paddingInPts;
|
||||
const widthOfEachPart = Math.round(pageWidthWithPadding * pxPerPt);
|
||||
console.log('widthOfEachPart', widthOfEachPart);
|
||||
const heightOfEachPart = Math.round(pageHeightWithPadding * pxPerPt);
|
||||
const imgParts = await splitImage(
|
||||
initImg,
|
||||
widthOfEachPart,
|
||||
heightOfEachPart
|
||||
);
|
||||
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
for (const imgFile of imgParts) {
|
||||
const imgArrayBuffer = await imgFile.arrayBuffer();
|
||||
const img = await pdfDoc.embedPng(imgArrayBuffer);
|
||||
const page = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
page.drawImage(img, {
|
||||
x: paddingInPts,
|
||||
y: paddingInPts,
|
||||
width: pageWidthWithPadding,
|
||||
height: pageHeightWithPadding
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfFile = new File(
|
||||
[pdfBytes as BlobPart],
|
||||
input.name.replace(/\.([^.]+)?$/i, `-${Date.now()}.pdf`),
|
||||
{ type: 'application/pdf' }
|
||||
);
|
||||
setResult(pdfFile);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedCompute = debounce(compute, 800);
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: t('image:split.pageParameters.title'),
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.pageFormat}
|
||||
onChange={(val) => {
|
||||
updateField('pageFormat', val as keyof typeof PageSizes);
|
||||
}}
|
||||
description={t('image:split.pageParameters.selectPageFormat')}
|
||||
options={Object.entries(PageSizes).map(([key, value]) => ({
|
||||
value: key,
|
||||
label: key
|
||||
}))}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
name="padding"
|
||||
type="number"
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
description={t('image:split.pageParameters.padding.description')}
|
||||
onOwnChange={(value) => {
|
||||
updateNumberField(value, 'padding', updateField);
|
||||
}}
|
||||
value={values.padding}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('image:split.scaleParameters.title'),
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
name="pxPerSquareQuantity"
|
||||
type="number"
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
description={t(
|
||||
'image:split.scaleParameters.pxPerSquareQuantity.description'
|
||||
)}
|
||||
onOwnChange={(value) => {
|
||||
updateNumberField(value, 'pxPerSquareQuantity', updateField);
|
||||
}}
|
||||
value={values.pxPerSquareQuantity}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
name="squareQuantity"
|
||||
type="number"
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
description={t(
|
||||
'image:split.scaleParameters.squareQuantity.description'
|
||||
)}
|
||||
onOwnChange={(value) => {
|
||||
updateNumberField(value, 'squareQuantity', updateField);
|
||||
}}
|
||||
value={values.squareQuantity}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
name="mmPerOneSquare"
|
||||
type="number"
|
||||
inputProps={{ min: 1, step: 1 }}
|
||||
description={t(
|
||||
'image:split.scaleParameters.unitsPerOneSquare.description'
|
||||
)}
|
||||
onOwnChange={(value) => {
|
||||
updateNumberField(value, 'unitsPerOneSquare', updateField);
|
||||
}}
|
||||
value={values.unitsPerOneSquare}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('image:split.units.title'),
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.unitKind}
|
||||
onChange={(newUnitKind) => {
|
||||
updateField('unitKind', newUnitKind);
|
||||
updateField('unitsPerOneSquare', fromPts(72, newUnitKind));
|
||||
updateField(
|
||||
'padding',
|
||||
Math.round(fromPts(toPts(5, 'mm'), newUnitKind) * 100) / 100
|
||||
);
|
||||
}}
|
||||
description={t('image:split.units.select')}
|
||||
options={[
|
||||
{ value: 'pt', label: t('image:split.units.options.pt') },
|
||||
{ value: 'mm', label: t('image:split.units.options.mm') },
|
||||
{ value: 'in', label: t('image:split.units.options.in') }
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={t('image:split.title')}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/*']}
|
||||
title={t('image:split.inputTitle')}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult title={t('image:split.resultTitle')} value={result} />
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={debouncedCompute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/pages/tools/image/generic/split/meta.ts
Normal file
14
src/pages/tools/image/generic/split/meta.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('image-generic', {
|
||||
i18n: {
|
||||
name: 'image:split.title',
|
||||
description: 'image:split.description',
|
||||
shortDescription: 'image:split.shortDescription'
|
||||
},
|
||||
path: 'split',
|
||||
icon: 'mdi:scissors',
|
||||
keywords: ['split'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
||||
85
src/pages/tools/image/generic/split/service.ts
Normal file
85
src/pages/tools/image/generic/split/service.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { units } from './types';
|
||||
|
||||
export async function splitImage(
|
||||
initImg: HTMLImageElement,
|
||||
widthOfEachPart: number,
|
||||
heightOfEachPart: number
|
||||
): Promise<File[]> {
|
||||
const { width, height } = initImg;
|
||||
const horParts = Math.ceil(width / widthOfEachPart);
|
||||
const verParts = Math.ceil(height / heightOfEachPart);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas not supported');
|
||||
|
||||
const promises: Promise<File>[] = [];
|
||||
|
||||
for (let y = 0; y < verParts; y++) {
|
||||
for (let x = 0; x < horParts; x++) {
|
||||
canvas.width = widthOfEachPart;
|
||||
canvas.height = heightOfEachPart;
|
||||
|
||||
ctx.clearRect(0, 0, widthOfEachPart, heightOfEachPart);
|
||||
ctx.drawImage(
|
||||
initImg,
|
||||
x * widthOfEachPart,
|
||||
y * heightOfEachPart,
|
||||
widthOfEachPart,
|
||||
heightOfEachPart,
|
||||
0,
|
||||
0,
|
||||
widthOfEachPart,
|
||||
heightOfEachPart
|
||||
);
|
||||
|
||||
const promise = canvasToFile(canvas, `part-${x}-${y}.png`);
|
||||
promises.push(promise);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
export function loadImage(input: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(input);
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToFile(canvas: HTMLCanvasElement, name: string): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(new File([blob], name, { type: 'image/png' }));
|
||||
} else {
|
||||
reject(new Error('Failed to create blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
export function toPts(number: number, units: units): number {
|
||||
switch (units) {
|
||||
case 'pt':
|
||||
return number;
|
||||
case 'mm':
|
||||
return (number * 72) / 25.4;
|
||||
case 'in':
|
||||
return number * 72;
|
||||
}
|
||||
}
|
||||
|
||||
export function fromPts(number: number, units: units): number {
|
||||
switch (units) {
|
||||
case 'pt':
|
||||
return number;
|
||||
case 'mm':
|
||||
return (number * 25.4) / 72;
|
||||
case 'in':
|
||||
return number / 72;
|
||||
}
|
||||
}
|
||||
16
src/pages/tools/image/generic/split/types.ts
Normal file
16
src/pages/tools/image/generic/split/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { PageSizes } from 'pdf-lib';
|
||||
|
||||
export type InitialValuesType = {
|
||||
pageFormat: keyof typeof PageSizes;
|
||||
widthOfEachPart?: number;
|
||||
heightOfEachPart?: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
pxPerSquareQuantity: number;
|
||||
squareQuantity: number;
|
||||
unitsPerOneSquare: number;
|
||||
unitKind: units;
|
||||
padding: number;
|
||||
};
|
||||
|
||||
export type units = 'pt' | 'mm' | 'in';
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min?url';
|
||||
import JSZip from 'jszip';
|
||||
import { zipFiles } from '@utils/zip';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ export async function convertPdfToPngImages(pdfFile: File): Promise<{
|
|||
}> {
|
||||
const arrayBuffer = await pdfFile.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
const zip = new JSZip();
|
||||
const images: ImagePreview[] = [];
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
|
|
@ -37,14 +36,11 @@ export async function convertPdfToPngImages(pdfFile: File): Promise<{
|
|||
const filename = `page-${i}.png`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
images.push({ blob, url, filename });
|
||||
zip.file(filename, blob);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({ type: 'arraybuffer' });
|
||||
const zipFile = new File(
|
||||
[zipBuffer],
|
||||
pdfFile.name.replace(/\.pdf$/i, '-pages.zip'),
|
||||
{ type: 'application/zip' }
|
||||
const zipFile = await zipFiles(
|
||||
images.map(({ blob, filename }) => ({ blob, filename })),
|
||||
pdfFile.name.replace(/\.pdf$/i, '-pages.zip')
|
||||
);
|
||||
|
||||
return { images, zipFile };
|
||||
|
|
|
|||
11
src/utils/zip.ts
Normal file
11
src/utils/zip.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import JSZip from 'jszip';
|
||||
|
||||
export async function zipFiles(
|
||||
blobs: { blob: Blob; filename: string }[],
|
||||
zipFilename: string
|
||||
): Promise<File> {
|
||||
const zip = new JSZip();
|
||||
blobs.forEach(({ blob, filename }) => zip.file(filename, blob));
|
||||
const zipBuffer = await zip.generateAsync({ type: 'arraybuffer' });
|
||||
return new File([zipBuffer], zipFilename, { type: 'application/zip' });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue