feat: add geometry masks
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
changeCanvasSize,
|
||||
contentBounds,
|
||||
cropToRatio,
|
||||
forceOrientation,
|
||||
padToRatio,
|
||||
symmetricCopy,
|
||||
trimToContent
|
||||
} from './geometry';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const bordered = () => {
|
||||
// 4×4: рамка из прозрачных пикселей вокруг красного центра 2×2
|
||||
const img = makeImage(4, 4, new Array(16).fill([0, 0, 0, 0]));
|
||||
for (let y = 1; y <= 2; y++) {
|
||||
for (let x = 1; x <= 2; x++) {
|
||||
const di = (y * 4 + x) * 4;
|
||||
img.data[di] = 255;
|
||||
img.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return img;
|
||||
};
|
||||
|
||||
describe('contentBounds / trimToContent', () => {
|
||||
it('границы по порогу альфы', () => {
|
||||
expect(contentBounds(bordered(), 0)).toEqual({ x: 1, y: 1, w: 2, h: 2 });
|
||||
});
|
||||
|
||||
it('trim обрезает поля и сохраняет содержимое', () => {
|
||||
const out = trimToContent(bordered(), 0);
|
||||
expect(out.width).toBe(2);
|
||||
expect(out.height).toBe(2);
|
||||
expect(out.data[0]).toBe(255);
|
||||
expect(out.data[3]).toBe(255);
|
||||
});
|
||||
|
||||
it('полностью прозрачное изображение → 1×1', () => {
|
||||
const empty = makeImage(3, 3, new Array(9).fill([0, 0, 0, 0]));
|
||||
const out = trimToContent(empty, 0);
|
||||
expect(out.width).toBe(1);
|
||||
expect(out.height).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeCanvasSize', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[1, 1, 1, 255],
|
||||
[2, 2, 2, 255],
|
||||
[3, 3, 3, 255],
|
||||
[4, 4, 4, 255]
|
||||
]);
|
||||
|
||||
it('увеличение с якорем center — прозрачные поля со всех сторон', () => {
|
||||
const out = changeCanvasSize(img, 4, 4, 'center');
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[(1 * 4 + 1) * 4 + 3]).toBe(255);
|
||||
});
|
||||
|
||||
it('увеличение с якорем top-left — контент прижат в угол', () => {
|
||||
const out = changeCanvasSize(img, 4, 4, 'top-left');
|
||||
expect(out.data[3]).toBe(255);
|
||||
expect(out.data[(3 * 4 + 3) * 4 + 3]).toBe(0);
|
||||
});
|
||||
|
||||
it('уменьшение обрезает как кроп от якоря bottom-right', () => {
|
||||
const out = changeCanvasSize(img, 1, 1, 'bottom-right');
|
||||
expect(out.data[0]).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('соотношение сторон', () => {
|
||||
const wide = makeImage(400, 200, new Array(80000).fill([9, 9, 9, 255]));
|
||||
|
||||
it('cropToRatio 1:1 из 2:1 → квадрат по высоте', () => {
|
||||
const out = cropToRatio(wide, 1);
|
||||
expect(out.width).toBe(200);
|
||||
expect(out.height).toBe(200);
|
||||
});
|
||||
|
||||
it('padToRatio 1:1 из 2:1 → квадрат с прозрачными полями', () => {
|
||||
const out = padToRatio(wide, 1);
|
||||
expect(out.width).toBe(400);
|
||||
expect(out.height).toBe(400);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect(out.data[(200 * 400 + 100) * 4 + 3]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceOrientation / symmetricCopy', () => {
|
||||
it('широкое становится высоким поворотом', () => {
|
||||
const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255]));
|
||||
const out = forceOrientation(wide, 'portrait');
|
||||
expect(out.width).toBe(100);
|
||||
expect(out.height).toBe(300);
|
||||
});
|
||||
|
||||
it('квадрат не поворачивается', () => {
|
||||
const sq = makeImage(50, 50, new Array(2500).fill([5, 5, 5, 255]));
|
||||
const out = forceOrientation(sq, 'portrait');
|
||||
expect(out.width).toBe(50);
|
||||
expect(out.height).toBe(50);
|
||||
});
|
||||
|
||||
it('симметричная копия удваивает ширину и зеркалит правую половину', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[10, 0, 0, 255],
|
||||
[20, 0, 0, 255]
|
||||
]);
|
||||
const out = symmetricCopy(img, 'vertical', 'left');
|
||||
expect(out.width).toBe(4);
|
||||
expect(out.data[0]).toBe(10);
|
||||
expect(out.data[8]).toBe(20); // пиксель 2 = зеркало начала
|
||||
expect(out.data[12]).toBe(10); // пиксель 3 = зеркало конца
|
||||
});
|
||||
|
||||
it('вертикальная ось удваивает высоту', () => {
|
||||
const img = makeImage(1, 2, [
|
||||
[10, 0, 0, 255],
|
||||
[30, 0, 0, 255]
|
||||
]);
|
||||
const out = symmetricCopy(img, 'horizontal', 'top');
|
||||
expect(out.height).toBe(4);
|
||||
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // строка 2 = зеркало строки 1
|
||||
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // строка 3 = зеркало строки 0
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseHex } from './alpha';
|
||||
import { parseHex } from './alpha';
|
||||
import { ToolError } from './errors';
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
@@ -213,3 +213,157 @@ export function sampleBilinear(
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type Anchor9 =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'middle-left'
|
||||
| 'center'
|
||||
| 'middle-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
/** Границы контента: пиксели с альфой строго больше порога. Пустое изображение → null. */
|
||||
export function contentBounds(
|
||||
img: PixelImage,
|
||||
alphaThreshold = 0
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
let minX = img.width;
|
||||
let minY = img.height;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
if (img.data[(y * img.width + x) * 4 + 3] > alphaThreshold) {
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return null;
|
||||
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
/** Обрезка прозрачных полей по порогу альфы. Полностью пустое → 1×1 прозрачный пиксель. */
|
||||
export function trimToContent(img: PixelImage, alphaThreshold = 0): PixelImage {
|
||||
const b = contentBounds(img, alphaThreshold);
|
||||
if (!b) return crop(img, 0, 0, 1, 1);
|
||||
return crop(img, b.x, b.y, b.w, b.h);
|
||||
}
|
||||
|
||||
/** Приводит холст к точному размеру: лишнее обрезается, недостающее дополняется прозрачным. */
|
||||
export function changeCanvasSize(
|
||||
img: PixelImage,
|
||||
width: number,
|
||||
height: number,
|
||||
anchor: Anchor9
|
||||
): PixelImage {
|
||||
const out = createPixelImage(width, height);
|
||||
const pasteX = anchor.endsWith('-left') ? 0 : anchor.endsWith('-right') ? width - img.width : Math.floor((width - img.width) / 2);
|
||||
const pasteY = anchor.startsWith('top-') ? 0 : anchor.startsWith('bottom-') ? height - img.height : Math.floor((height - img.height) / 2);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const sy = y - pasteY;
|
||||
if (sy < 0 || sy >= img.height) continue;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const sx = x - pasteX;
|
||||
if (sx < 0 || sx >= img.width) continue;
|
||||
const di = (y * width + x) * 4;
|
||||
const si = (sy * img.width + sx) * 4;
|
||||
out.data[di] = img.data[si];
|
||||
out.data[di + 1] = img.data[si + 1];
|
||||
out.data[di + 2] = img.data[si + 2];
|
||||
out.data[di + 3] = img.data[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Центральный кроп до соотношения сторон (ratio ≥ 1 = широкое). */
|
||||
export function cropToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
const current = img.width / img.height;
|
||||
if (current > ratio) {
|
||||
const w = Math.max(1, Math.round(img.height * ratio));
|
||||
return crop(img, Math.floor((img.width - w) / 2), 0, w, img.height);
|
||||
}
|
||||
if (current < ratio) {
|
||||
const h = Math.max(1, Math.round(img.width / ratio));
|
||||
return crop(img, 0, Math.floor((img.height - h) / 2), img.width, h);
|
||||
}
|
||||
return clonePixelImage(img);
|
||||
}
|
||||
|
||||
/** Вписывает в соотношение сторон, добавляя прозрачные поля. */
|
||||
export function padToRatio(img: PixelImage, ratio: number): PixelImage {
|
||||
const current = img.width / img.height;
|
||||
let w = img.width;
|
||||
let h = img.height;
|
||||
if (current > ratio) h = Math.round(w / ratio);
|
||||
else if (current < ratio) w = Math.round(h * ratio);
|
||||
w = Math.max(1, w);
|
||||
h = Math.max(1, h);
|
||||
return changeCanvasSize(img, w, h, 'center');
|
||||
}
|
||||
|
||||
/** Разворачивает изображение на 90°, если его ориентация не совпадает с целевой. Квадрат не трогает. */
|
||||
export function forceOrientation(img: PixelImage, target: 'portrait' | 'landscape'): PixelImage {
|
||||
const current = img.width > img.height ? 'landscape' : img.width < img.height ? 'portrait' : 'square';
|
||||
if (current === target || current === 'square') return clonePixelImage(img);
|
||||
return rotate90(img, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Симметричная копия: к выбранной стороне оригинала добавляется его зеркало.
|
||||
* axis vertical — зеркалим по вертикальной линии (ширина ×2), horizontal — по горизонтальной (высота ×2).
|
||||
*/
|
||||
export function symmetricCopy(
|
||||
img: PixelImage,
|
||||
axis: 'vertical' | 'horizontal',
|
||||
keepSide: 'left' | 'right' | 'top' | 'bottom'
|
||||
): PixelImage {
|
||||
if (axis === 'vertical') {
|
||||
const out = createPixelImage(img.width * 2, img.height);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const srcX = keepSide === 'left' ? x : img.width - 1 - x;
|
||||
const di = (y * out.width + x) * 4;
|
||||
const si = (y * img.width + srcX) * 4;
|
||||
out.data[di] = img.data[si];
|
||||
out.data[di + 1] = img.data[si + 1];
|
||||
out.data[di + 2] = img.data[si + 2];
|
||||
out.data[di + 3] = img.data[si + 3];
|
||||
const mx = img.width * 2 - 1 - x;
|
||||
const md = (y * out.width + mx) * 4;
|
||||
out.data[md] = img.data[si];
|
||||
out.data[md + 1] = img.data[si + 1];
|
||||
out.data[md + 2] = img.data[si + 2];
|
||||
out.data[md + 3] = img.data[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const out = createPixelImage(img.width, img.height * 2);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const srcY = keepSide === 'top' ? y : img.height - 1 - y;
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const si = (srcY * img.width + x) * 4;
|
||||
const dTop = (y * out.width + x) * 4;
|
||||
out.data[dTop] = img.data[si];
|
||||
out.data[dTop + 1] = img.data[si + 1];
|
||||
out.data[dTop + 2] = img.data[si + 2];
|
||||
out.data[dTop + 3] = img.data[si + 3];
|
||||
const my = img.height * 2 - 1 - y;
|
||||
const md = (my * out.width + x) * 4;
|
||||
out.data[md] = img.data[si];
|
||||
out.data[md + 1] = img.data[si + 1];
|
||||
out.data[md + 2] = img.data[si + 2];
|
||||
out.data[md + 3] = img.data[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user