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
|
||||
- [ ] bar code to text, text to bar code
|
||||
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
|
||||
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
|
||||
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
|
||||
схема + core, из задачи C3)
|
||||
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
|
||||
выделенной области
|
||||
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
function isHidden(spec: FieldSpec): boolean {
|
||||
const rule = spec.visibleWhen;
|
||||
return rule !== undefined && values[rule.field] !== rule.equals;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isEmpty}
|
||||
@@ -99,16 +104,19 @@
|
||||
: undefined}
|
||||
>
|
||||
{#each group.fields as id (id)}
|
||||
{@const Control = FIELDS[schema.fields[id].spec.kind]}
|
||||
<Control
|
||||
label={fieldLabel(schema.fields[id], id)}
|
||||
value={values[id]}
|
||||
spec={effectiveSpec(schema.fields[id].spec)}
|
||||
{toolId}
|
||||
fieldId={id}
|
||||
{sourceDims}
|
||||
onchange={(v, axis) => onchange(id, v, axis)}
|
||||
/>
|
||||
{@const spec = schema.fields[id].spec}
|
||||
{@const Control = FIELDS[spec.kind]}
|
||||
{#if !isHidden(spec)}
|
||||
<Control
|
||||
label={fieldLabel(schema.fields[id], id)}
|
||||
value={values[id]}
|
||||
spec={effectiveSpec(spec)}
|
||||
{toolId}
|
||||
fieldId={id}
|
||||
{sourceDims}
|
||||
onchange={(v, axis) => onchange(id, v, axis)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resize,
|
||||
rotate90,
|
||||
splitToParts,
|
||||
splitToTileSize,
|
||||
tile,
|
||||
} from "./geometry";
|
||||
import { expectImageEqual, makeImage } from "./test-helpers";
|
||||
@@ -322,3 +323,63 @@ describe("splitToParts", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */
|
||||
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 {
|
||||
let minX = img.width;
|
||||
let minY = img.height;
|
||||
|
||||
@@ -235,6 +235,8 @@ export const en: Dict = {
|
||||
colorTolerance: "Color tolerance",
|
||||
cols: "Cols",
|
||||
columns: "Columns",
|
||||
splitMode: "Split by",
|
||||
tileSize: "Tile size",
|
||||
component: "Component",
|
||||
contrast: "Contrast",
|
||||
count: "Count",
|
||||
@@ -323,6 +325,8 @@ export const en: Dict = {
|
||||
cropArea: "Crop area",
|
||||
fill: "Fill",
|
||||
grid: "Grid",
|
||||
splitMode: "Split by",
|
||||
tileSize: "Tile size",
|
||||
noise: "Noise",
|
||||
offset: "Offset",
|
||||
options: "Options",
|
||||
@@ -375,6 +379,11 @@ export const en: Dict = {
|
||||
orientationSquare: "Square — the sides are equal.",
|
||||
},
|
||||
},
|
||||
"split-into-parts": {
|
||||
options: {
|
||||
mode: { grid: "Grid", tile: "Tile size" },
|
||||
},
|
||||
},
|
||||
"show-transparent": {
|
||||
options: {
|
||||
mode: { binary: "Black & white mask", highlight: "Color highlight" },
|
||||
|
||||
@@ -236,6 +236,8 @@ export const ru: Dict = {
|
||||
colorTolerance: "Допуск похожести",
|
||||
cols: "Колонки",
|
||||
columns: "Столбцы",
|
||||
splitMode: "Делить по",
|
||||
tileSize: "Размер тайла",
|
||||
component: "Компонент",
|
||||
contrast: "Контраст",
|
||||
count: "Количество",
|
||||
@@ -324,6 +326,8 @@ export const ru: Dict = {
|
||||
cropArea: "Область кадрирования",
|
||||
fill: "Заливка",
|
||||
grid: "Сетка",
|
||||
splitMode: "Режим разделения",
|
||||
tileSize: "Размер тайла",
|
||||
noise: "Шум",
|
||||
offset: "Смещение",
|
||||
options: "Параметры",
|
||||
@@ -994,6 +998,17 @@ export const ru: Dict = {
|
||||
color: "Цвет фона",
|
||||
},
|
||||
},
|
||||
"split-into-parts": {
|
||||
params: {
|
||||
mode: "Режим",
|
||||
columns: "Столбцов",
|
||||
rows: "Строк",
|
||||
tile: "Размер тайла",
|
||||
},
|
||||
options: {
|
||||
mode: { grid: "Сетка", tile: "Размер тайла" },
|
||||
},
|
||||
},
|
||||
tile: {
|
||||
params: { columns: "Столбцов", rows: "Строк" },
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Position9 } from "./core/textdraw";
|
||||
*/
|
||||
export interface FieldSpecBase {
|
||||
label?: string;
|
||||
/** Поле видно только когда другое поле равно значению (режимы инструмента). */
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}
|
||||
|
||||
export interface NumberSpec extends FieldSpecBase {
|
||||
@@ -291,6 +293,7 @@ export const field = {
|
||||
maxFromSource?: boolean;
|
||||
lockAspectWith?: string;
|
||||
presets?: boolean;
|
||||
visibleWhen?: { field: string; equals: string };
|
||||
}): Field<Dimension> => ({
|
||||
spec: { kind: "dimension", ...s },
|
||||
}),
|
||||
|
||||
@@ -760,6 +760,25 @@ describe("split-into-parts", () => {
|
||||
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 при переполнении", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
|
||||
expect(() =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resize,
|
||||
rotate90,
|
||||
splitToParts,
|
||||
splitToTileSize,
|
||||
symmetricCopy,
|
||||
tile,
|
||||
trimToContent,
|
||||
@@ -437,26 +438,58 @@ const tileTool: Tool<TileParams> = {
|
||||
const MAX_SPLIT_PARTS = 1024;
|
||||
|
||||
interface SplitPartsParams {
|
||||
mode: "grid" | "tile";
|
||||
columns: number;
|
||||
rows: number;
|
||||
tile: Dimension;
|
||||
}
|
||||
|
||||
export const splitPartsSchema = toolSchema<SplitPartsParams>({
|
||||
columns: field.number({
|
||||
label: "fields.columns",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 2,
|
||||
}),
|
||||
rows: field.number({
|
||||
label: "fields.rows",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 2,
|
||||
}),
|
||||
});
|
||||
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({
|
||||
label: "fields.columns",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 2,
|
||||
visibleWhen: { field: "mode", equals: "grid" },
|
||||
}),
|
||||
rows: field.number({
|
||||
label: "fields.rows",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
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> = {
|
||||
id: "split-into-parts",
|
||||
@@ -466,8 +499,20 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
||||
run: (ctx) => {
|
||||
const img = requireSource(ctx);
|
||||
const params = ctx.params as SplitPartsParams;
|
||||
const cols = Math.max(1, Math.trunc(params.columns));
|
||||
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
||||
const cols =
|
||||
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;
|
||||
if (count > MAX_SPLIT_PARTS) {
|
||||
throw new ToolError("errors.tooManyParts", {
|
||||
@@ -475,7 +520,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
||||
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 colLen = String(cols).length;
|
||||
const files: ToolImageFile[] = parts.map((image, index) => {
|
||||
|
||||
Reference in New Issue
Block a user