From 9ccc9f94acb5cc9d6f5ab1c13a7b3641160e3edd Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 22 Aug 2026 09:49:53 +0500 Subject: [PATCH] feat: core instruments --- web/package.json | 6 +- web/pnpm-lock.yaml | 234 ++++++++++++++++++++++++++++++ web/src/lib/core/alpha.test.ts | 68 +++++++++ web/src/lib/core/alpha.ts | 47 ++++++ web/src/lib/core/analyze.test.ts | 45 ++++++ web/src/lib/core/analyze.ts | 26 ++++ web/src/lib/core/color.test.ts | 69 +++++++++ web/src/lib/core/color.ts | 47 ++++++ web/src/lib/core/geometry.test.ts | 157 ++++++++++++++++++++ web/src/lib/core/geometry.ts | 106 ++++++++++++++ web/src/lib/core/test-helpers.ts | 14 ++ web/src/lib/core/types.ts | 4 + web/vitest.config.ts | 8 + 13 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/core/alpha.test.ts create mode 100644 web/src/lib/core/alpha.ts create mode 100644 web/src/lib/core/analyze.test.ts create mode 100644 web/src/lib/core/analyze.ts create mode 100644 web/src/lib/core/color.test.ts create mode 100644 web/src/lib/core/color.ts create mode 100644 web/src/lib/core/geometry.test.ts create mode 100644 web/src/lib/core/geometry.ts create mode 100644 web/src/lib/core/test-helpers.ts create mode 100644 web/vitest.config.ts diff --git a/web/package.json b/web/package.json index 24dac41..f9e32ef 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", @@ -18,6 +19,7 @@ "svelte": "^5.56.1", "svelte-check": "^4.6.0", "typescript": "^6.0.3", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.11" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 874a01e..e386a17 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: vite: specifier: ^8.0.16 version: 8.2.1 + vitest: + specifier: ^4.1.11 + version: 4.1.11(vite@8.2.1) packages: @@ -187,15 +190,50 @@ packages: svelte: ^5.46.4 vite: ^8.0.0-beta.7 || ^8.0.0 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -205,10 +243,18 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -217,6 +263,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -232,6 +281,9 @@ packages: devalue@5.9.0: resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esm-env@1.2.2: resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} @@ -243,6 +295,13 @@ packages: '@typescript-eslint/types': optional: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -364,6 +423,9 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -391,6 +453,9 @@ packages: set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -399,6 +464,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + svelte-check@4.7.6: resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} engines: {node: '>= 18.0.0'} @@ -411,10 +482,21 @@ packages: resolution: {integrity: sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -475,6 +557,52 @@ packages: vite: optional: true + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -588,24 +716,78 @@ snapshots: vite: 8.2.1 vitefu: 1.1.3(vite@8.2.1) + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/trusted-types@2.0.7': {} + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.1)': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1 + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + acorn@8.18.0: {} aria-query@5.3.1: {} + assertion-error@2.0.1: {} + axobject-query@4.1.0: {} + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 clsx@2.1.1: {} + convert-source-map@2.0.0: {} + cookie@0.6.0: {} deepmerge@4.3.1: {} @@ -614,12 +796,20 @@ snapshots: devalue@5.9.0: {} + es-module-lexer@2.3.2: {} + esm-env@1.2.2: {} esrap@2.3.5: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -700,6 +890,8 @@ snapshots: obug@2.1.4: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -738,6 +930,8 @@ snapshots: set-cookie-parser@3.1.2: {} + siginfo@2.0.0: {} + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -746,6 +940,10 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + svelte-check@4.7.6(picomatch@4.0.5)(svelte@5.56.9)(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -780,11 +978,17 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + totalist@3.0.1: {} typescript@6.0.3: {} @@ -803,4 +1007,34 @@ snapshots: optionalDependencies: vite: 8.2.1 + vitest@4.1.11(vite@8.2.1): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + zimmerframe@1.1.4: {} diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts new file mode 100644 index 0000000..8fc3473 --- /dev/null +++ b/web/src/lib/core/alpha.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { parseHex, removeColorToAlpha } from './alpha'; +import { makeImage } from './test-helpers'; + +describe('removeColorToAlpha', () => { + const fixture = () => + makeImage(2, 1, [ + [255, 255, 255, 255], + [255, 0, 0, 255] + ]); + + it('обнуляет альфу только у точного совпадения при tolerance=0', () => { + const out = removeColorToAlpha(fixture(), '#ff0000', 0); + expect(out.data[3]).toBe(255); + expect(out.data[7]).toBe(0); + expect(out.data[4]).toBe(255); + expect(out.data[5]).toBe(0); + }); + + it('поддерживает короткую форму и регистр hex', () => { + expect([...removeColorToAlpha(fixture(), '#f00', 0).data.slice(4)]).toEqual([ + 255, 0, 0, 0 + ]); + expect([...removeColorToAlpha(fixture(), '#FF0000', 0).data.slice(4)]).toEqual([ + 255, 0, 0, 0 + ]); + }); + + it('tolerance 100% удаляет весь диапазон расстояний', () => { + const out = removeColorToAlpha(fixture(), '#000000', 100); + expect(out.data[3]).toBe(0); + expect(out.data[7]).toBe(0); + }); + + it('промежуточный tolerance различает близкие и далёкие цвета', () => { + const img = makeImage(2, 1, [ + [0, 0, 0, 255], + [128, 128, 128, 255] + ]); + const kept = removeColorToAlpha(img, '#000000', 40); + const removed = removeColorToAlpha(img, '#000000', 60); + expect(kept.data[3]).toBe(0); + expect(kept.data[7]).toBe(255); + expect(removed.data[3]).toBe(0); + expect(removed.data[7]).toBe(0); + }); + + it('не мутирует вход', () => { + const img = fixture(); + removeColorToAlpha(img, '#ffffff', 100); + expect([...img.data]).toEqual([ + 255, 255, 255, 255, + 255, 0, 0, 255 + ]); + }); +}); + +describe('parseHex', () => { + it('разбирает #rrggbb, rrggbb, #rgb', () => { + expect(parseHex('#ff8040')).toEqual([255, 128, 64]); + expect(parseHex('ff8040')).toEqual([255, 128, 64]); + expect(parseHex('#F80')).toEqual([255, 136, 0]); + }); + + it.each(['zzz', '12345', '##ff', ''])('бросает ошибку на "%s"', (bad) => { + expect(() => parseHex(bad)).toThrow(/Некорректный HEX/); + }); +}); diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts new file mode 100644 index 0000000..0635a57 --- /dev/null +++ b/web/src/lib/core/alpha.ts @@ -0,0 +1,47 @@ +import type { PixelImage } from './types'; + +const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255); + +export function removeColorToAlpha( + img: PixelImage, + hex: string, + tolerancePercent = 0 +): PixelImage { + const [targetR, targetG, targetB] = parseHex(hex); + const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE; + const thresholdSq = tolerance * tolerance; + const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() }; + for (let i = 0; i < out.data.length; i += 4) { + const dr = out.data[i] - targetR; + const dg = out.data[i + 1] - targetG; + const db = out.data[i + 2] - targetB; + if (dr * dr + dg * dg + db * db <= thresholdSq) { + out.data[i + 3] = 0; + } + } + return out; +} + +export function parseHex(hex: string): [number, number, number] { + const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) { + throw new Error(`Некорректный HEX-цвет: "${hex}"`); + } + const digits = match[1]; + if (digits.length === 3) { + return [ + parseInt(digits[0] + digits[0], 16), + parseInt(digits[1] + digits[1], 16), + parseInt(digits[2] + digits[2], 16) + ]; + } + return [ + parseInt(digits.slice(0, 2), 16), + parseInt(digits.slice(2, 4), 16), + parseInt(digits.slice(4, 6), 16) + ]; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/web/src/lib/core/analyze.test.ts b/web/src/lib/core/analyze.test.ts new file mode 100644 index 0000000..8437780 --- /dev/null +++ b/web/src/lib/core/analyze.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { imageInfo } from './analyze'; +import { makeImage } from './test-helpers'; + +describe('imageInfo', () => { + it('находит полупрозрачные пиксели и считает уникальные RGBA-цвета', () => { + const info = imageInfo( + makeImage(2, 2, [ + [0, 0, 0, 255], + [0, 0, 0, 255], + [255, 0, 0, 128], + [255, 0, 0, 128] + ]) + ); + expect(info).toEqual({ width: 2, height: 2, hasAlpha: true, colorCount: 2 }); + }); + + it('полностью непрозрачное изображение — hasAlpha false', () => { + const info = imageInfo( + makeImage(2, 1, [ + [10, 20, 30, 255], + [40, 50, 60, 255] + ]) + ); + expect(info.hasAlpha).toBe(false); + expect(info.colorCount).toBe(2); + }); + + it('разная альфа означает разные цвета', () => { + const info = imageInfo( + makeImage(2, 1, [ + [255, 0, 0, 255], + [255, 0, 0, 128] + ]) + ); + expect(info.hasAlpha).toBe(true); + expect(info.colorCount).toBe(2); + }); + + it('возвращает корректные размеры неквадрата', () => { + const info = imageInfo(makeImage(5, 3, new Array(15).fill([1, 2, 3, 4]))); + expect(info.width).toBe(5); + expect(info.height).toBe(3); + }); +}); diff --git a/web/src/lib/core/analyze.ts b/web/src/lib/core/analyze.ts new file mode 100644 index 0000000..4508e0b --- /dev/null +++ b/web/src/lib/core/analyze.ts @@ -0,0 +1,26 @@ +import type { PixelImage } from './types'; + +export type ImageInfo = { + width: number; + height: number; + hasAlpha: boolean; + colorCount: number; +}; + +export function imageInfo(img: PixelImage): ImageInfo { + let hasAlpha = false; + const seen = new Set(); + for (let i = 0; i < img.data.length; i += 4) { + if (!hasAlpha && img.data[i + 3] !== 255) { + hasAlpha = true; + } + const key = + ((img.data[i] << 24) | + (img.data[i + 1] << 16) | + (img.data[i + 2] << 8) | + img.data[i + 3]) >>> + 0; + seen.add(key); + } + return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size }; +} diff --git a/web/src/lib/core/color.test.ts b/web/src/lib/core/color.test.ts new file mode 100644 index 0000000..7714499 --- /dev/null +++ b/web/src/lib/core/color.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { brightnessContrast, grayscale, invert } from './color'; +import { makeImage } from './test-helpers'; + +describe('grayscale', () => { + it('считает luma по весам BT.601 с округлением', () => { + const out = grayscale( + makeImage(3, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255] + ]) + ); + const rgb = [...out.data].reduce((acc, v, i) => { + if (i % 4 === 0) acc.push(v); + return acc; + }, []); + expect(rgb).toEqual([76, 150, 29]); + }); + + it('сохраняет альфу и не мутирует вход', () => { + const img = makeImage(1, 1, [[10, 20, 30, 200]]); + const out = grayscale(img); + expect([...out.data]).toEqual([18, 18, 18, 200]); + expect([...img.data]).toEqual([10, 20, 30, 200]); + }); +}); + +describe('invert', () => { + it('инвертирует RGB, не трогая альфу', () => { + const out = invert(makeImage(1, 1, [[10, 200, 30, 7]])); + expect([...out.data]).toEqual([245, 55, 225, 7]); + }); +}); + +describe('brightnessContrast', () => { + const pixel = (r: number) => makeImage(1, 1, [[r, r, r, 255]]); + const red = (out: number[]) => [out[0], out[1], out[2]]; + + it('b=0, c=0 — тождественное преобразование', () => { + const out = brightnessContrast(pixel(77), 0, 0); + expect(red([...out.data])).toEqual([77, 77, 77]); + }); + + it('brightness +100 насыщает всё в белый', () => { + const out = brightnessContrast(pixel(10), 100, 0); + expect(red([...out.data])).toEqual([255, 255, 255]); + }); + + it('brightness -100 заливает чёрным', () => { + const out = brightnessContrast(pixel(240), -100, 0); + expect(red([...out.data])).toEqual([0, 0, 0]); + }); + + it('contrast -100 сводит всё к серому 128', () => { + const out = brightnessContrast(pixel(30), 0, -100); + expect(red([...out.data])).toEqual([128, 128, 128]); + }); + + it('параметры вне диапазона клампятся', () => { + const out = brightnessContrast(pixel(10), 150, 0); + expect(red([...out.data])).toEqual([255, 255, 255]); + }); + + it('альфа не меняется', () => { + const out = brightnessContrast(makeImage(1, 1, [[10, 20, 30, 64]]), 50, 50); + expect(out.data[3]).toBe(64); + }); +}); diff --git a/web/src/lib/core/color.ts b/web/src/lib/core/color.ts new file mode 100644 index 0000000..188185f --- /dev/null +++ b/web/src/lib/core/color.ts @@ -0,0 +1,47 @@ +import { createPixelImage, type PixelImage } from './types'; + +export function grayscale(img: PixelImage): PixelImage { + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < img.data.length; i += 4) { + const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]; + out.data[i] = luma; + out.data[i + 1] = luma; + out.data[i + 2] = luma; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function invert(img: PixelImage): PixelImage { + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < img.data.length; i += 4) { + out.data[i] = 255 - img.data[i]; + out.data[i + 1] = 255 - img.data[i + 1]; + out.data[i + 2] = 255 - img.data[i + 2]; + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +export function brightnessContrast( + img: PixelImage, + brightness: number, + contrast: number +): PixelImage { + const offset = (clamp(brightness, -100, 100) / 100) * 255; + const c = (clamp(contrast, -100, 100) / 100) * 255; + const factor = (259 * (c + 255)) / (255 * (259 - c)); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < img.data.length; i += 4) { + for (let ch = 0; ch < 3; ch++) { + const v = factor * (img.data[i + ch] + offset - 128) + 128; + out.data[i + ch] = v; + } + out.data[i + 3] = img.data[i + 3]; + } + return out; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/web/src/lib/core/geometry.test.ts b/web/src/lib/core/geometry.test.ts new file mode 100644 index 0000000..bd709a9 --- /dev/null +++ b/web/src/lib/core/geometry.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { crop, flip, resize, rotate90 } from './geometry'; +import { makeImage } from './test-helpers'; + +const square = () => + makeImage(2, 2, [ + [1, 1, 1, 1], + [2, 2, 2, 2], + [3, 3, 3, 3], + [4, 4, 4, 4] + ]); + +describe('flip', () => { + it('отражает по горизонтали (зеркало слева-направо)', () => { + const out = flip(square(), 'horizontal'); + expect(out.width).toBe(2); + expect(out.height).toBe(2); + expect([...out.data]).toEqual([ + 2, 2, 2, 2, + 1, 1, 1, 1, + 4, 4, 4, 4, + 3, 3, 3, 3 + ]); + }); + + it('отражает по вертикали (сверху-вниз)', () => { + const out = flip(square(), 'vertical'); + expect([...out.data]).toEqual([ + 3, 3, 3, 3, + 4, 4, 4, 4, + 1, 1, 1, 1, + 2, 2, 2, 2 + ]); + }); + + it('не мутирует вход', () => { + const img = square(); + flip(img, 'horizontal'); + expect([...img.data]).toEqual([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]); + }); +}); + +describe('rotate90', () => { + it('поворачивает неквадрат 2x3 на 90° по часовой', () => { + const rect = makeImage(2, 3, [ + [1, 1, 1, 1], + [2, 2, 2, 2], + [3, 3, 3, 3], + [4, 4, 4, 4], + [5, 5, 5, 5], + [6, 6, 6, 6] + ]); + const out = rotate90(rect, 1); + expect(out.width).toBe(3); + expect(out.height).toBe(2); + expect([...out.data]).toEqual([ + 5, 5, 5, 5, + 3, 3, 3, 3, + 1, 1, 1, 1, + 6, 6, 6, 6, + 4, 4, 4, 4, + 2, 2, 2, 2 + ]); + }); + + it('turns=0 возвращает копию без изменений', () => { + const img = square(); + const out = rotate90(img, 0); + expect(out).not.toBe(img); + expect([...out.data]).toEqual([...img.data]); + }); + + it('нормализует turns: 5 ≡ 1, -3 ≡ 1, 4 ≡ 0', () => { + const once = [...rotate90(square(), 1).data]; + expect([...rotate90(square(), 5).data]).toEqual(once); + expect([...rotate90(square(), -3).data]).toEqual(once); + expect([...rotate90(square(), 4).data]).toEqual([...square().data]); + }); + + it('turns=2 на квадрате равно двойному отражению', () => { + const out = rotate90(square(), 2); + expect([...out.data]).toEqual([ + 4, 4, 4, 4, + 3, 3, 3, 3, + 2, 2, 2, 2, + 1, 1, 1, 1 + ]); + }); +}); + +describe('crop', () => { + const grid = () => + makeImage(3, 3, [ + [1, 1, 1, 1], + [2, 2, 2, 2], + [3, 3, 3, 3], + [4, 4, 4, 4], + [5, 5, 5, 5], + [6, 6, 6, 6], + [7, 7, 7, 7], + [8, 8, 8, 8], + [9, 9, 9, 9] + ]); + + it('вырезает центральную область 2x2 из 3x3', () => { + const out = crop(grid(), 1, 1, 2, 2); + expect(out.width).toBe(2); + expect(out.height).toBe(2); + expect([...out.data]).toEqual([ + 5, 5, 5, 5, + 6, 6, 6, 6, + 8, 8, 8, 8, + 9, 9, 9, 9 + ]); + }); + + it('усекает область, выходящую за границы', () => { + const out = crop(grid(), -1, -1, 2, 2); + expect(out.width).toBe(1); + expect(out.height).toBe(1); + expect([...out.data]).toEqual([1, 1, 1, 1]); + }); + + it('бросает RangeError для области вне изображения', () => { + expect(() => crop(grid(), 5, 5, 2, 2)).toThrow(RangeError); + }); +}); + +describe('resize', () => { + const twoByTwo = () => + makeImage(2, 2, [ + [0, 0, 0, 255], + [100, 100, 100, 255], + [200, 200, 200, 255], + [255, 255, 255, 255] + ]); + + it('совпадает с входом при тех же размерах', () => { + const img = twoByTwo(); + expect([...resize(img, 2, 2).data]).toEqual([...img.data]); + }); + + it('апскейл 2x2 -> 4x4 билинейно интерполирует', () => { + const out = resize(twoByTwo(), 4, 4); + expect(out.width).toBe(4); + expect(out.height).toBe(4); + const red = [...out.data].filter((_, i) => i % 4 === 0); + expect(red.slice(0, 4)).toEqual([0, 25, 75, 100]); + expect(red.slice(4, 8)).toEqual([50, 72, 117, 139]); + }); + + it('бросает RangeError на некорректные размеры', () => { + const img = twoByTwo(); + expect(() => resize(img, 0, 10)).toThrow(RangeError); + expect(() => resize(img, 10.5, 10)).toThrow(RangeError); + }); +}); diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts new file mode 100644 index 0000000..2e7c88d --- /dev/null +++ b/web/src/lib/core/geometry.ts @@ -0,0 +1,106 @@ +import { clonePixelImage, createPixelImage, type PixelImage } from './types'; + +export type FlipAxis = 'horizontal' | 'vertical'; + +export function flip(img: PixelImage, axis: FlipAxis): PixelImage { + const out = createPixelImage(img.width, img.height); + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const sx = axis === 'horizontal' ? img.width - 1 - x : x; + const sy = axis === 'vertical' ? img.height - 1 - y : y; + copyPixel(img, sx, sy, out, x, y); + } + } + return out; +} + +export function rotate90(img: PixelImage, turns: number): PixelImage { + const t = ((Math.trunc(turns) % 4) + 4) % 4; + if (t === 0) return clonePixelImage(img); + let current = img; + for (let i = 0; i < t; i++) { + current = rotateClockwise(current); + } + return current; +} + +function rotateClockwise(img: PixelImage): PixelImage { + const out = createPixelImage(img.height, img.width); + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + copyPixel(img, x, y, out, img.height - 1 - y, x); + } + } + return out; +} + +export function crop( + img: PixelImage, + x: number, + y: number, + width: number, + height: number +): PixelImage { + const sx = clampInt(x, 0, img.width); + const sy = clampInt(y, 0, img.height); + const ex = clampInt(x + width, sx, img.width); + const ey = clampInt(y + height, sy, img.height); + const w = ex - sx; + const h = ey - sy; + if (w <= 0 || h <= 0) { + throw new RangeError('Область обрезки пуста: она целиком вне изображения'); + } + const out = createPixelImage(w, h); + for (let row = 0; row < h; row++) { + const si = (sy + row) * img.width * 4 + sx * 4; + out.data.set(img.data.subarray(si, si + w * 4), row * w * 4); + } + return out; +} + +export function resize(img: PixelImage, width: number, height: number): PixelImage { + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { + throw new RangeError('Размеры должны быть целыми числами >= 1'); + } + const out = createPixelImage(width, height); + const xr = img.width / width; + const yr = img.height / height; + const maxX = img.width - 1; + const maxY = img.height - 1; + for (let dy = 0; dy < height; dy++) { + const fy = Math.min(Math.max((dy + 0.5) * yr - 0.5, 0), maxY); + const y0 = Math.floor(fy); + const ty = fy - y0; + const y1 = Math.min(y0 + 1, maxY); + for (let dx = 0; dx < width; dx++) { + const fx = Math.min(Math.max((dx + 0.5) * xr - 0.5, 0), maxX); + const x0 = Math.floor(fx); + const tx = fx - x0; + const x1 = Math.min(x0 + 1, maxX); + const di = (dy * width + dx) * 4; + for (let ch = 0; ch < 4; ch++) { + const p00 = img.data[(y0 * img.width + x0) * 4 + ch]; + const p10 = img.data[(y0 * img.width + x1) * 4 + ch]; + const p01 = img.data[(y1 * img.width + x0) * 4 + ch]; + const p11 = img.data[(y1 * img.width + x1) * 4 + ch]; + const top = (1 - tx) * p00 + tx * p10; + const bottom = (1 - tx) * p01 + tx * p11; + out.data[di + ch] = (1 - ty) * top + ty * bottom; + } + } + } + return out; +} + +function copyPixel(src: PixelImage, sx: number, sy: number, dst: PixelImage, dx: number, dy: number): void { + const si = (sy * src.width + sx) * 4; + const di = (dy * dst.width + dx) * 4; + dst.data[di] = src.data[si]; + dst.data[di + 1] = src.data[si + 1]; + dst.data[di + 2] = src.data[si + 2]; + dst.data[di + 3] = src.data[si + 3]; +} + +function clampInt(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} diff --git a/web/src/lib/core/test-helpers.ts b/web/src/lib/core/test-helpers.ts new file mode 100644 index 0000000..3202472 --- /dev/null +++ b/web/src/lib/core/test-helpers.ts @@ -0,0 +1,14 @@ +import { expect } from 'vitest'; +import type { PixelImage } from './types'; + +export function makeImage(width: number, height: number, pixels: number[][]): PixelImage { + const data = new Uint8ClampedArray(pixels.flat()); + if (data.length !== width * height * 4) { + throw new Error(`Fixture mismatch: ${data.length} байт на ${width}x${height}`); + } + return { width, height, data }; +} + +export function expectImageEqual(actual: PixelImage, expectedPixels: number[][]): void { + expect([...actual.data]).toEqual(expectedPixels.flat()); +} diff --git a/web/src/lib/core/types.ts b/web/src/lib/core/types.ts index 1a2fae0..4d066be 100644 --- a/web/src/lib/core/types.ts +++ b/web/src/lib/core/types.ts @@ -11,3 +11,7 @@ export function createPixelImage(width: number, height: number): PixelImage { data: new Uint8ClampedArray(width * height * 4) }; } + +export function clonePixelImage(img: PixelImage): PixelImage { + return { width: img.width, height: img.height, data: img.data.slice() }; +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..b4cb01b --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node' + } +});