mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add split to parts by tile size
This commit is contained in:
@@ -65,10 +65,6 @@
|
|||||||
другой инструмент с хардкодед настройками?
|
другой инструмент с хардкодед настройками?
|
||||||
- [ ] qr code to text, text to qr code
|
- [ ] qr code to text, text to qr code
|
||||||
- [ ] bar code to text, text to bar code
|
- [ ] bar code to text, text to bar code
|
||||||
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
|
|
||||||
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
|
|
||||||
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
|
|
||||||
схема + core, из задачи C3)
|
|
||||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||||
выделенной области
|
выделенной области
|
||||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||||
|
|||||||
@@ -83,6 +83,11 @@
|
|||||||
}
|
}
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHidden(spec: FieldSpec): boolean {
|
||||||
|
const rule = spec.visibleWhen;
|
||||||
|
return rule !== undefined && values[rule.field] !== rule.equals;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isEmpty}
|
{#if isEmpty}
|
||||||
@@ -99,16 +104,19 @@
|
|||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
{#each group.fields as id (id)}
|
{#each group.fields as id (id)}
|
||||||
{@const Control = FIELDS[schema.fields[id].spec.kind]}
|
{@const spec = schema.fields[id].spec}
|
||||||
|
{@const Control = FIELDS[spec.kind]}
|
||||||
|
{#if !isHidden(spec)}
|
||||||
<Control
|
<Control
|
||||||
label={fieldLabel(schema.fields[id], id)}
|
label={fieldLabel(schema.fields[id], id)}
|
||||||
value={values[id]}
|
value={values[id]}
|
||||||
spec={effectiveSpec(schema.fields[id].spec)}
|
spec={effectiveSpec(spec)}
|
||||||
{toolId}
|
{toolId}
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
{sourceDims}
|
{sourceDims}
|
||||||
onchange={(v, axis) => onchange(id, v, axis)}
|
onchange={(v, axis) => onchange(id, v, axis)}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resize,
|
resize,
|
||||||
rotate90,
|
rotate90,
|
||||||
splitToParts,
|
splitToParts,
|
||||||
|
splitToTileSize,
|
||||||
tile,
|
tile,
|
||||||
} from "./geometry";
|
} from "./geometry";
|
||||||
import { expectImageEqual, makeImage } from "./test-helpers";
|
import { expectImageEqual, makeImage } from "./test-helpers";
|
||||||
@@ -322,3 +323,63 @@ describe("splitToParts", () => {
|
|||||||
expect(parts[0].height).toBe(6);
|
expect(parts[0].height).toBe(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("splitToTileSize", () => {
|
||||||
|
function grid(w: number, h: number): PixelImage {
|
||||||
|
const pixels: number[][] = [];
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const v = y * w + x + 1;
|
||||||
|
pixels.push([v, v, v, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return makeImage(w, h, pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("кратный размер: части ровно tileW×tileH без паддинга", () => {
|
||||||
|
const parts = splitToTileSize(grid(4, 4), 2, 2);
|
||||||
|
expect(parts).toHaveLength(4);
|
||||||
|
for (const part of parts) {
|
||||||
|
expect(part.width).toBe(2);
|
||||||
|
expect(part.height).toBe(2);
|
||||||
|
}
|
||||||
|
expectImageEqual(parts[1], [
|
||||||
|
[3, 3, 3, 255],
|
||||||
|
[4, 4, 4, 255],
|
||||||
|
[7, 7, 7, 255],
|
||||||
|
[8, 8, 8, 255],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("некратный размер: канвас дополняется прозрачным до кратности", () => {
|
||||||
|
const parts = splitToTileSize(grid(5, 3), 3, 2);
|
||||||
|
expect(parts).toHaveLength(4);
|
||||||
|
for (const part of parts) {
|
||||||
|
expect(part.width).toBe(3);
|
||||||
|
expect(part.height).toBe(2);
|
||||||
|
}
|
||||||
|
expectImageEqual(parts[1], [
|
||||||
|
[4, 4, 4, 255],
|
||||||
|
[5, 5, 5, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[9, 9, 9, 255],
|
||||||
|
[10, 10, 10, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
]);
|
||||||
|
expectImageEqual(parts[2], [
|
||||||
|
[11, 11, 11, 255],
|
||||||
|
[12, 12, 12, 255],
|
||||||
|
[13, 13, 13, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("тайл больше картинки — одна часть с паддингом", () => {
|
||||||
|
const parts = splitToTileSize(grid(2, 2), 4, 4);
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0].width).toBe(4);
|
||||||
|
expect(parts[0].height).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -87,6 +87,31 @@ export function splitToParts(
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */
|
||||||
|
export function splitToTileSize(
|
||||||
|
img: PixelImage,
|
||||||
|
tileW: number,
|
||||||
|
tileH: number,
|
||||||
|
): PixelImage[] {
|
||||||
|
const tw = Math.max(1, Math.trunc(tileW));
|
||||||
|
const th = Math.max(1, Math.trunc(tileH));
|
||||||
|
const cols = Math.max(1, Math.ceil(img.width / tw));
|
||||||
|
const rows = Math.max(1, Math.ceil(img.height / th));
|
||||||
|
const gridW = cols * tw;
|
||||||
|
const gridH = rows * th;
|
||||||
|
const padded =
|
||||||
|
gridW === img.width && gridH === img.height
|
||||||
|
? img
|
||||||
|
: expandCanvas(img, 0, 0, gridW - img.width, gridH - img.height);
|
||||||
|
const parts: PixelImage[] = [];
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
parts.push(crop(padded, col * tw, row * th, tw, th));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
export function centerByAlpha(img: PixelImage): PixelImage {
|
export function centerByAlpha(img: PixelImage): PixelImage {
|
||||||
let minX = img.width;
|
let minX = img.width;
|
||||||
let minY = img.height;
|
let minY = img.height;
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ export const en: Dict = {
|
|||||||
colorTolerance: "Color tolerance",
|
colorTolerance: "Color tolerance",
|
||||||
cols: "Cols",
|
cols: "Cols",
|
||||||
columns: "Columns",
|
columns: "Columns",
|
||||||
|
splitMode: "Split by",
|
||||||
|
tileSize: "Tile size",
|
||||||
component: "Component",
|
component: "Component",
|
||||||
contrast: "Contrast",
|
contrast: "Contrast",
|
||||||
count: "Count",
|
count: "Count",
|
||||||
@@ -323,6 +325,8 @@ export const en: Dict = {
|
|||||||
cropArea: "Crop area",
|
cropArea: "Crop area",
|
||||||
fill: "Fill",
|
fill: "Fill",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
|
splitMode: "Split by",
|
||||||
|
tileSize: "Tile size",
|
||||||
noise: "Noise",
|
noise: "Noise",
|
||||||
offset: "Offset",
|
offset: "Offset",
|
||||||
options: "Options",
|
options: "Options",
|
||||||
@@ -375,6 +379,11 @@ export const en: Dict = {
|
|||||||
orientationSquare: "Square — the sides are equal.",
|
orientationSquare: "Square — the sides are equal.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"split-into-parts": {
|
||||||
|
options: {
|
||||||
|
mode: { grid: "Grid", tile: "Tile size" },
|
||||||
|
},
|
||||||
|
},
|
||||||
"show-transparent": {
|
"show-transparent": {
|
||||||
options: {
|
options: {
|
||||||
mode: { binary: "Black & white mask", highlight: "Color highlight" },
|
mode: { binary: "Black & white mask", highlight: "Color highlight" },
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ export const ru: Dict = {
|
|||||||
colorTolerance: "Допуск похожести",
|
colorTolerance: "Допуск похожести",
|
||||||
cols: "Колонки",
|
cols: "Колонки",
|
||||||
columns: "Столбцы",
|
columns: "Столбцы",
|
||||||
|
splitMode: "Делить по",
|
||||||
|
tileSize: "Размер тайла",
|
||||||
component: "Компонент",
|
component: "Компонент",
|
||||||
contrast: "Контраст",
|
contrast: "Контраст",
|
||||||
count: "Количество",
|
count: "Количество",
|
||||||
@@ -324,6 +326,8 @@ export const ru: Dict = {
|
|||||||
cropArea: "Область кадрирования",
|
cropArea: "Область кадрирования",
|
||||||
fill: "Заливка",
|
fill: "Заливка",
|
||||||
grid: "Сетка",
|
grid: "Сетка",
|
||||||
|
splitMode: "Режим разделения",
|
||||||
|
tileSize: "Размер тайла",
|
||||||
noise: "Шум",
|
noise: "Шум",
|
||||||
offset: "Смещение",
|
offset: "Смещение",
|
||||||
options: "Параметры",
|
options: "Параметры",
|
||||||
@@ -994,6 +998,17 @@ export const ru: Dict = {
|
|||||||
color: "Цвет фона",
|
color: "Цвет фона",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"split-into-parts": {
|
||||||
|
params: {
|
||||||
|
mode: "Режим",
|
||||||
|
columns: "Столбцов",
|
||||||
|
rows: "Строк",
|
||||||
|
tile: "Размер тайла",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
mode: { grid: "Сетка", tile: "Размер тайла" },
|
||||||
|
},
|
||||||
|
},
|
||||||
tile: {
|
tile: {
|
||||||
params: { columns: "Столбцов", rows: "Строк" },
|
params: { columns: "Столбцов", rows: "Строк" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { Position9 } from "./core/textdraw";
|
|||||||
*/
|
*/
|
||||||
export interface FieldSpecBase {
|
export interface FieldSpecBase {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Поле видно только когда другое поле равно значению (режимы инструмента). */
|
||||||
|
visibleWhen?: { field: string; equals: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NumberSpec extends FieldSpecBase {
|
export interface NumberSpec extends FieldSpecBase {
|
||||||
@@ -291,6 +293,7 @@ export const field = {
|
|||||||
maxFromSource?: boolean;
|
maxFromSource?: boolean;
|
||||||
lockAspectWith?: string;
|
lockAspectWith?: string;
|
||||||
presets?: boolean;
|
presets?: boolean;
|
||||||
|
visibleWhen?: { field: string; equals: string };
|
||||||
}): Field<Dimension> => ({
|
}): Field<Dimension> => ({
|
||||||
spec: { kind: "dimension", ...s },
|
spec: { kind: "dimension", ...s },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -760,6 +760,25 @@ describe("split-into-parts", () => {
|
|||||||
expect(files).toHaveLength(1024);
|
expect(files).toHaveLength(1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("режим tile: сетка считается от размера тайла, части точные", async () => {
|
||||||
|
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
||||||
|
const files = asFiles(
|
||||||
|
await tool.run({
|
||||||
|
source: solid(100, 70),
|
||||||
|
params: sanitizeSchemaParams(tool.schema, {
|
||||||
|
mode: "tile",
|
||||||
|
tile: { width: 32, height: 32 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).files;
|
||||||
|
// 100/32 → 4 колонки, 70/32 → 3 строки
|
||||||
|
expect(files).toHaveLength(12);
|
||||||
|
for (const file of files) {
|
||||||
|
expect(file.image.width).toBe(32);
|
||||||
|
expect(file.image.height).toBe(32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("выбрасывает errors.tooManyParts при переполнении", () => {
|
it("выбрасывает errors.tooManyParts при переполнении", () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
resize,
|
resize,
|
||||||
rotate90,
|
rotate90,
|
||||||
splitToParts,
|
splitToParts,
|
||||||
|
splitToTileSize,
|
||||||
symmetricCopy,
|
symmetricCopy,
|
||||||
tile,
|
tile,
|
||||||
trimToContent,
|
trimToContent,
|
||||||
@@ -437,17 +438,29 @@ const tileTool: Tool<TileParams> = {
|
|||||||
const MAX_SPLIT_PARTS = 1024;
|
const MAX_SPLIT_PARTS = 1024;
|
||||||
|
|
||||||
interface SplitPartsParams {
|
interface SplitPartsParams {
|
||||||
|
mode: "grid" | "tile";
|
||||||
columns: number;
|
columns: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
|
tile: Dimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
export const splitPartsSchema = toolSchema<SplitPartsParams>(
|
||||||
|
{
|
||||||
|
mode: field.select({
|
||||||
|
label: "fields.splitMode",
|
||||||
|
default: "grid",
|
||||||
|
options: [
|
||||||
|
{ value: "grid", label: "Grid" },
|
||||||
|
{ value: "tile", label: "Tile size" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
columns: field.number({
|
columns: field.number({
|
||||||
label: "fields.columns",
|
label: "fields.columns",
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 32,
|
max: 32,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 2,
|
default: 2,
|
||||||
|
visibleWhen: { field: "mode", equals: "grid" },
|
||||||
}),
|
}),
|
||||||
rows: field.number({
|
rows: field.number({
|
||||||
label: "fields.rows",
|
label: "fields.rows",
|
||||||
@@ -455,8 +468,28 @@ export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
|||||||
max: 32,
|
max: 32,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 2,
|
default: 2,
|
||||||
|
visibleWhen: { field: "mode", equals: "grid" },
|
||||||
}),
|
}),
|
||||||
});
|
tile: field.dimension({
|
||||||
|
label: "fields.tileSize",
|
||||||
|
min: 1,
|
||||||
|
max: 20000,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
presets: true,
|
||||||
|
visibleWhen: { field: "mode", equals: "tile" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "groups.splitMode", fields: ["mode"] },
|
||||||
|
{ title: "groups.grid", fields: ["columns", "rows"] },
|
||||||
|
{ title: "groups.tileSize", fields: ["tile"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const splitPartsTool: Tool<SplitPartsParams> = {
|
const splitPartsTool: Tool<SplitPartsParams> = {
|
||||||
id: "split-into-parts",
|
id: "split-into-parts",
|
||||||
@@ -466,8 +499,20 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
|||||||
run: (ctx) => {
|
run: (ctx) => {
|
||||||
const img = requireSource(ctx);
|
const img = requireSource(ctx);
|
||||||
const params = ctx.params as SplitPartsParams;
|
const params = ctx.params as SplitPartsParams;
|
||||||
const cols = Math.max(1, Math.trunc(params.columns));
|
const cols =
|
||||||
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
params.mode === "tile"
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(img.width / Math.max(1, Math.trunc(params.tile.width))),
|
||||||
|
)
|
||||||
|
: Math.max(1, Math.trunc(params.columns));
|
||||||
|
const rowsCount =
|
||||||
|
params.mode === "tile"
|
||||||
|
? Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(img.height / Math.max(1, Math.trunc(params.tile.height))),
|
||||||
|
)
|
||||||
|
: Math.max(1, Math.trunc(params.rows));
|
||||||
const count = cols * rowsCount;
|
const count = cols * rowsCount;
|
||||||
if (count > MAX_SPLIT_PARTS) {
|
if (count > MAX_SPLIT_PARTS) {
|
||||||
throw new ToolError("errors.tooManyParts", {
|
throw new ToolError("errors.tooManyParts", {
|
||||||
@@ -475,7 +520,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
|||||||
max: MAX_SPLIT_PARTS,
|
max: MAX_SPLIT_PARTS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const parts = splitToParts(img, cols, rowsCount);
|
const parts =
|
||||||
|
params.mode === "tile"
|
||||||
|
? splitToTileSize(img, params.tile.width, params.tile.height)
|
||||||
|
: splitToParts(img, cols, rowsCount);
|
||||||
const rowLen = String(rowsCount).length;
|
const rowLen = String(rowsCount).length;
|
||||||
const colLen = String(cols).length;
|
const colLen = String(cols).length;
|
||||||
const files: ToolImageFile[] = parts.map((image, index) => {
|
const files: ToolImageFile[] = parts.map((image, index) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user