This commit is contained in:
DemetriSam 2025-10-29 21:07:43 +01:00 committed by GitHub
commit 6506c69828
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 431 additions and 9 deletions

View file

@ -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."
}
}
}

View file

@ -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. Удобно для печати карт на нескольких листах."
}
}
}

View file

@ -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
];

View 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 }}
/>
);
}

View 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'))
});

View 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;
}
}

View 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';

View file

@ -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
View 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' });
}