fix: resolve transform core tools, add tests

This commit is contained in:
2026-08-24 18:51:11 +05:00
parent 8745812a5f
commit ebc3c33c12
3 changed files with 95 additions and 61 deletions
+34 -7
View File
@@ -67,14 +67,19 @@ describe('rotateFreeImage', () => {
}); });
describe('skewImage', () => { describe('skewImage', () => {
it('наклон X=45° сдвигает верхний правый угол', () => { it('наклон X=45° расширяет холст до w+h−1 и сдвигает строки вправо', () => {
const img = makeImage(2, 2, [ const img = makeImage(2, 2, new Array(4).fill([255, 0, 0, 255]));
[255, 0, 0, 255], [0, 255, 0, 255],
[0, 0, 255, 255], [128, 128, 128, 255]
]);
const out = skewImage(img, 45, 0); const out = skewImage(img, 45, 0);
expect(out.width).toBeGreaterThan(2); expect(out.width).toBe(3);
expect(out.data[3]).toBe(255); expect(out.height).toBe(2);
let opaque = 0;
for (let i = 3; i < out.data.length; i += 4) {
if (out.data[i] === 255) {
opaque++;
expect([out.data[i - 3], out.data[i - 2], out.data[i - 1]]).toEqual([255, 0, 0]);
}
}
expect(opaque).toBe(4);
}); });
it('углы 0° — тождественное преобразование', () => { it('углы 0° — тождественное преобразование', () => {
@@ -87,3 +92,25 @@ describe('skewImage', () => {
expect([...out.data]).toEqual([...img.data]); expect([...out.data]).toEqual([...img.data]);
}); });
}); });
describe('transformImage — заливка фона', () => {
it('сдвиг с белым фоном заполняет освободившийся край', () => {
const img = makeImage(2, 2, [
[255, 0, 0, 255], [0, 255, 0, 255],
[0, 0, 255, 255], [128, 128, 128, 255]
]);
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2, '#ffffff');
expect(out.width).toBe(2);
expect(out.height).toBe(2);
for (let y = 0; y < 2; y++) {
const di = (y * 2) * 4;
expect([...out.data.slice(di, di + 4)]).toEqual([255, 255, 255, 255]);
}
});
it('без фона освободившийся край остаётся прозрачным', () => {
const img = makeImage(2, 2, new Array(4).fill([10, 20, 30, 255]));
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2);
expect(out.data[3]).toBe(0);
});
});
+58 -52
View File
@@ -1,3 +1,4 @@
import { parseHex } from './alpha';
import { clonePixelImage, createPixelImage, type PixelImage } from './types'; import { clonePixelImage, createPixelImage, type PixelImage } from './types';
import { sampleBilinear } from './geometry'; import { sampleBilinear } from './geometry';
@@ -15,61 +16,75 @@ export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
return [ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]; return [ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)];
} }
function mulAffine(m1: AffineMatrix, m2: AffineMatrix): AffineMatrix { const EPS = 1e-9;
return [
m1[0] * m2[0] + m1[2] * m2[1],
m1[1] * m2[0] + m1[3] * m2[1],
m1[0] * m2[2] + m1[2] * m2[3],
m1[1] * m2[2] + m1[3] * m2[3],
m1[0] * m2[4] + m1[2] * m2[5] + m1[4],
m1[1] * m2[4] + m1[3] * m2[5] + m1[5]
];
}
export function transformImage( export function transformImage(
img: PixelImage, img: PixelImage,
dstToSrc: AffineMatrix, dstToSrc: AffineMatrix,
outWidth: number, outWidth: number,
outHeight: number outHeight: number,
bgHex?: string
): PixelImage { ): PixelImage {
const [a, b, c, d, e, f] = invertAffine(dstToSrc); const [a, b, c, d, e, f] = dstToSrc;
const out = createPixelImage(outWidth, outHeight); const out = createPixelImage(outWidth, outHeight);
const bg = bgHex ? parseHex(bgHex) : null;
for (let y = 0; y < outHeight; y++) { for (let y = 0; y < outHeight; y++) {
for (let x = 0; x < outWidth; x++) { for (let x = 0; x < outWidth; x++) {
const sx = a * x + c * y + e; const sx = a * x + c * y + e;
const sy = b * x + d * y + f; const sy = b * x + d * y + f;
const di = (y * outWidth + x) * 4; const di = (y * outWidth + x) * 4;
if (sx < -1 || sy < -1 || sx > img.width || sy > img.height) continue; if (sx < -EPS || sy < -EPS || sx > img.width - 1 + EPS || sy > img.height - 1 + EPS) {
if (bg) {
out.data[di] = bg[0];
out.data[di + 1] = bg[1];
out.data[di + 2] = bg[2];
out.data[di + 3] = 255;
}
continue;
}
const [r, g, bl, al] = sampleBilinear(img, sx, sy); const [r, g, bl, al] = sampleBilinear(img, sx, sy);
out.data[di] = r; if (bg) {
out.data[di + 1] = g; const sa = al / 255;
out.data[di + 2] = bl; out.data[di] = r * sa + bg[0] * (1 - sa);
out.data[di + 3] = al; out.data[di + 1] = g * sa + bg[1] * (1 - sa);
out.data[di + 2] = bl * sa + bg[2] * (1 - sa);
out.data[di + 3] = 255;
} else {
out.data[di] = r;
out.data[di + 1] = g;
out.data[di + 2] = bl;
out.data[di + 3] = al;
}
} }
} }
return out; return out;
} }
function transformCorners( function centeredTransform(img: PixelImage, forward: AffineMatrix): PixelImage {
img: PixelImage, const inv = invertAffine(forward);
m: AffineMatrix
): { minX: number; minY: number; outW: number; outH: number } { let minX = Infinity;
const pts = [ let maxX = -Infinity;
[0, 0], let minY = Infinity;
[img.width, 0], let maxY = -Infinity;
[0, img.height], for (const [px, py] of [
[img.width, img.height] [0.5, 0.5],
].map(([x, y]) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]); [img.width - 0.5, 0.5],
const xs = pts.map((p) => p[0]); [0.5, img.height - 0.5],
const ys = pts.map((p) => p[1]); [img.width - 0.5, img.height - 0.5]
const minX = Math.min(...xs); ]) {
const minY = Math.min(...ys); const qx = forward[0] * px + forward[2] * py + forward[4];
return { const qy = forward[1] * px + forward[3] * py + forward[5];
minX, minX = Math.min(minX, qx);
minY, maxX = Math.max(maxX, qx);
outW: Math.ceil(Math.max(...xs) - minX), minY = Math.min(minY, qy);
outH: Math.ceil(Math.max(...ys) - minY) maxY = Math.max(maxY, qy);
}; }
const outW = Math.round(maxX - minX) + 1;
const outH = Math.round(maxY - minY) + 1;
const tx = inv[0] * minX + inv[2] * minY - 0.5;
const ty = inv[1] * minX + inv[3] * minY - 0.5;
return transformImage(img, [inv[0], inv[1], inv[2], inv[3], tx, ty], outW, outH);
} }
export function skewImage(img: PixelImage, degX: number, degY: number): PixelImage { export function skewImage(img: PixelImage, degX: number, degY: number): PixelImage {
@@ -78,25 +93,16 @@ export function skewImage(img: PixelImage, degX: number, degY: number): PixelIma
if (!Number.isFinite(kx) || !Number.isFinite(ky)) { if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
throw new Error('Углы наклона не могут быть 90° или -90°'); throw new Error('Углы наклона не могут быть 90° или -90°');
} }
const forward: AffineMatrix = [1, ky, kx, 1, 0, 0]; return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
const bounds = transformCorners(img, forward);
const toOrigin: AffineMatrix = [1, 0, 0, 1, -bounds.minX, -bounds.minY];
return transformImage(img, mulAffine(invertAffine(forward), invertAffine(toOrigin)), bounds.outW, bounds.outH);
} }
export function rotateFreeImage(img: PixelImage, degrees: number): PixelImage { export function rotateFreeImage(img: PixelImage, degrees: number): PixelImage {
const rad = (degrees * Math.PI) / 180; const rad = (degrees * Math.PI) / 180;
const cos = Math.cos(rad); let cos = Math.cos(rad);
const sin = Math.sin(rad); let sin = Math.sin(rad);
const forward: AffineMatrix = [cos, sin, -sin, cos, 0, 0]; if (Math.abs(sin) < 1e-12) sin = 0;
const bounds = transformCorners(img, forward); if (Math.abs(cos) < 1e-12) cos = 0;
const toOrigin: AffineMatrix = [1, 0, 0, 1, -bounds.minX, -bounds.minY]; return centeredTransform(img, [cos, sin, -sin, cos, 0, 0]);
return transformImage(
img,
mulAffine(invertAffine(forward), invertAffine(toOrigin)),
bounds.outW,
bounds.outH
);
} }
export function zoomImage(img: PixelImage, scalePercent: number): PixelImage { export function zoomImage(img: PixelImage, scalePercent: number): PixelImage {
+3 -2
View File
@@ -914,7 +914,8 @@ export const TOOLS: ToolEntry[] = [
img, img,
[1, 0, 0, 1, -Math.trunc(num(p, 'offsetX')), -Math.trunc(num(p, 'offsetY'))], [1, 0, 0, 1, -Math.trunc(num(p, 'offsetX')), -Math.trunc(num(p, 'offsetY'))],
img.width, img.width,
img.height img.height,
str(p, 'color')
) )
}, },
{ {
@@ -959,7 +960,7 @@ export const TOOLS: ToolEntry[] = [
{ {
id: 'temperature-png', id: 'temperature-png',
title: 'Температура PNG', title: 'Температура PNG',
description: 'Отрицательные значения делают изображение теплее (оранжевее), положительные — холоднее (синеватее).', description: 'Положительные значения делают изображение теплее (оранжевее), отрицательные — холоднее (синеватее).',
category: 'color', category: 'color',
params: [ params: [
{ id: 'percent', label: 'Температура', type: 'slider', min: -100, max: 100, step: 1, default: 0 } { id: 'percent', label: 'Температура', type: 'slider', min: -100, max: 100, step: 1, default: 0 }