From e7fb847e3b61a7cd1f4324438c61f7ad8cbfd68a Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Wed, 19 Aug 2026 07:28:54 +0000 Subject: [PATCH 01/16] refactor: remove OCR types and worker implementation; add schema matching utilities - Deleted `ocr-types.ts` and `ocr-worker.ts` files as part of the refactor. - Introduced `startrick.ts` with schema matching functions for validating and manipulating data structures. - Removed `vuejsawait.ts` for cleaner codebase. - Updated `tsconfig.json` to change the root directory for TypeScript compilation. --- builder/source/build.ts | 11 - package.json | 6 +- pnpm-workspace.yaml | 1 + testunit/package.json | 37 +++ testunit/tests/index.test.ts | 255 +++++++++++++++ testunit/tsconfig.json | 14 + userscript/source/dom-await.ts | 25 -- userscript/source/index.ts | 255 +++++---------- userscript/source/ocr-client.ts | 548 -------------------------------- userscript/source/ocr-types.ts | 40 --- userscript/source/ocr-worker.ts | 516 ------------------------------ userscript/source/startrick.ts | 233 ++++++++++++++ userscript/source/vuejsawait.ts | 133 -------- userscript/tsconfig.json | 2 +- 14 files changed, 623 insertions(+), 1453 deletions(-) create mode 100644 testunit/package.json create mode 100644 testunit/tests/index.test.ts create mode 100644 testunit/tsconfig.json delete mode 100644 userscript/source/dom-await.ts delete mode 100644 userscript/source/ocr-client.ts delete mode 100644 userscript/source/ocr-types.ts delete mode 100644 userscript/source/ocr-worker.ts create mode 100644 userscript/source/startrick.ts delete mode 100644 userscript/source/vuejsawait.ts diff --git a/builder/source/build.ts b/builder/source/build.ts index a32b405..4b1e6ec 100644 --- a/builder/source/build.ts +++ b/builder/source/build.ts @@ -110,14 +110,6 @@ export async function Build(OptionsParam?: BuildOptions): Promise { } }) - const WorkerCode = await ESBuild.build({ - entryPoints: [Path.resolve(ProjectRoot, 'userscript', 'source', 'ocr-worker.ts')], - bundle: true, - minify: Options.Minify, - write: false, - target: ['es2024', 'chrome119', 'firefox142', 'safari26'] - }) - const VirtualIndexEntry = await CreateVirtualIndexEntry(ProjectRoot) await ESBuild.build({ @@ -130,9 +122,6 @@ export async function Build(OptionsParam?: BuildOptions): Promise { js: Banner }, target: ['es2024', 'chrome119', 'firefox142', 'safari26'], - define: { - __OCR_WORKER_CODE__: JSON.stringify(WorkerCode.outputFiles[0].text) - }, plugins: [ CreateVirtualIndexEntryPlugin(VirtualIndexEntry.EntryPath, VirtualIndexEntry.FileSystem) ] diff --git a/package.json b/package.json index 6f9cf01..a8894cd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build:stable": "npm run build -w builder -- --minify true --use-cache false --build-type production --SubscriptionUrl https://cdn.jsdelivr.net/npm/@filteringdev/namulink@latest/dist/NamuLink.user.js", "build:dev": "npm run build -w builder -- --minify false --use-cache false --build-type production --SubscriptionUrl https://cdn.jsdelivr.net/npm/@filteringdev/namulink@latest/dist/NamuLink.user.js", "debug": "npm run debug -w builder", - "lint": "npm run lint -w builder && npm run lint -w userscript" + "lint": "npm run lint -w builder && npm run lint -w userscript", + "test": "npm run test -w testunit" }, "keywords": [ "namu.wiki", @@ -23,7 +24,8 @@ "license": "MPL-2.0", "workspaces": [ "userscript", - "builder" + "builder", + "testunit" ], "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.59.4", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1e7ac8a..0fb673e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "builder" - "userscript" + - "testunit" allowBuilds: esbuild: true diff --git a/testunit/package.json b/testunit/package.json new file mode 100644 index 0000000..78e1128 --- /dev/null +++ b/testunit/package.json @@ -0,0 +1,37 @@ +{ + "name": "@filteringdev/namulink-testunit", + "private": true, + "type": "module", + "scripts": { + "lint": "tsc --noEmit && eslint **/*.ts", + "test": "ava" + }, + "ava": { + "files": [ + "tests/**/*.test.ts" + ], + "nodeArguments": [ + "--import=tsx" + ], + "workerThreads": false, + "typescript": { + "rewritePaths": { + "@userscript/": "./sources/" + }, + "compile": false + } + }, + "devDependencies": { + "@ava/typescript": "^7.0.0", + "@types/node": "^24.13.1", + "@types/web": "^0.0.345", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", + "@violentmonkey/types": "^0.3.3", + "ava": "^8.0.1", + "eslint": "^10.4.0", + "fast-check": "^4.9.0", + "tsx": "^4.22.4", + "typescript-eslint": "^8.59.4" + } +} diff --git a/testunit/tests/index.test.ts b/testunit/tests/index.test.ts new file mode 100644 index 0000000..9721c41 --- /dev/null +++ b/testunit/tests/index.test.ts @@ -0,0 +1,255 @@ +import test from 'ava' +import { MatchSchema, AddrSchema, MatchValueSchema, AddrValueSchema, ParsePath, AsPathValue, SetValueAtPath, DeleteValueAtPath } from '@userscript/startrick.js' + +test('returns JSONPaths for matching values at matching structural paths', T => { + const Value = { + profile: { name: 'Ada' }, + tags: ['wiki'], + 'display-name': 'NamuLink', + ignored: 'not matched', + } + const Schema = { + profile: { name: /^Ada$/ }, + tags: [/^wiki$/], + 'display-name': /^NamuLink$/, + ignored: /^missing$/, + } + + T.deepEqual(MatchSchema(Value, Schema), ['$.profile.name', '$.tags[0]', '$[\'display-name\']']) + T.true(AddrSchema(Value, Schema)) +}) + +test('matches safely stringifiable primitive values only', T => { + const UnsafeObject = { + toString(): never { throw new Error('String must not be called') }, + valueOf(): never { throw new Error('valueOf must not be called') }, + } + const Value = { + text: 'text', + number: 42, + boolean: true, + bigint: 7n, + symbol: Symbol('mark'), + nil: null, + undefined: undefined, + object: UnsafeObject, + fn: () => 'text', + } + const Schema = { + text: /^text$/, + number: /^42$/, + boolean: /^true$/, + bigint: /^7$/, + symbol: /^Symbol\(mark\)$/, + nil: /^null$/, + undefined: /^undefined$/, + object: /^\[object Object\]$/, + fn: /^text$/, + } + + T.deepEqual(MatchSchema(Value, Schema), [ + '$.text', '$.number', '$.boolean', '$.bigint', '$.symbol', '$.nil', '$.undefined', + ]) +}) + +test('handles nesting deeper than the call stack', T => { + const Depth = 20_000 + let Value: Record = { leaf: 'target' } + let Schema: Record = { leaf: /^target$/ } + + for (let Index = 0; Index < Depth; Index++) { + Value = { next: Value } + Schema = { next: Schema } + } + + T.deepEqual(MatchSchema(Value, Schema), [`$${'.next'.repeat(Depth)}.leaf`]) +}) + +test('requires the value and schema to share a path', T => { + T.deepEqual( + MatchSchema({ value: { target: 'match' } }, { value: { other: /^match$/ } }), + [], + ) +}) + +test('MatchValueSchema matches regardless of randomized property names/order', T => { + const Schemas = [/^[0-9]{8,12}$/, /^[01]$/, /^host\.example\.com$/] + const Value = { x9f2: '1', qz1: 'host.example.com', a: '123456789' } + + T.deepEqual(MatchValueSchema(Value, Schemas).sort(), ['$.a', '$.qz1', '$.x9f2']) + T.true(AddrValueSchema(Value, Schemas)) +}) + +test('MatchValueSchema containment mode allows extra properties', T => { + const Schemas = [/^[0-9]{8,12}$/, /^[01]$/] + const Value = { a: '123456789', b: '1', c: 'extra', d: 'more-extra' } + + T.deepEqual(MatchValueSchema(Value, Schemas).sort(), ['$.a', '$.b']) + T.deepEqual(MatchValueSchema(Value, Schemas, { Exact: true }), []) +}) + +test('MatchValueSchema exact mode requires the same number of properties as schemas', T => { + const Schemas = [/^[0-9]{8,12}$/, /^[01]$/] + const Value = { a: '123456789', b: '1' } + + T.deepEqual(MatchValueSchema(Value, Schemas, { Exact: true }).sort(), ['$.a', '$.b']) +}) + +test('MatchValueSchema requires a distinct value per regex (no reuse via bipartite matching)', T => { + // Only one value ("1") can satisfy /^[01]$/, but two schemas require it - no perfect matching exists. + const Schemas = [/^[01]$/, /^[01]$/] + const Value = { a: '1', b: 'not-a-flag' } + + T.deepEqual(MatchValueSchema(Value, Schemas), []) +}) + +test('MatchValueSchema matches nested SSR values inside randomized object properties', T => { + const Image = /\/\/i\.namu\.wiki\/i\/[a-zA-Z0-9-_]+\.[a-z]{3,4}/ + const Schemas = [ + [Image, Image, Image, Image, Image], + [[/[a-z0-9]{4,6}/], [/[a-z0-9]{4,6}/]], + [Image, Image], + ] as const + const Value = { + mode: 'vertical', + ads: [ + { title: 'piano', link: 'piano1.co.kr', labels: [{ text: '상담' }] }, + { title: 'intry', link: 'intry.co.kr', labels: [{ text: '견적' }] }, + ], + firstImages: [ + '//i.namu.wiki/i/one.png', '//i.namu.wiki/i/two.svg', '//i.namu.wiki/i/three.png', + '//i.namu.wiki/i/four.svg', '//i.namu.wiki/i/five.png', + ], + secondImages: ['//i.namu.wiki/i/six.png', '//i.namu.wiki/i/seven.svg'], + } + + T.deepEqual(MatchValueSchema(Value, Schemas).sort(), ['$.ads', '$.firstImages', '$.secondImages']) + T.deepEqual(MatchValueSchema({ ...Value, ads: [{ title: 'only-korean', labels: [{ text: '광고' }] }] }, Schemas), []) +}) + +test('ParsePath parses identifier, index, and escaped-key segments produced by AddPathSegment', T => { + T.deepEqual(ParsePath('$'), []) + T.deepEqual(ParsePath('$.profile.name'), ['profile', 'name']) + T.deepEqual(ParsePath('$.tags[0]'), ['tags', 0]) + T.deepEqual(ParsePath('$[\'display-name\']'), ['display-name']) + T.deepEqual(ParsePath('$[\'it\\\'s\\\\here\']'), ['it\'s\\here']) +}) + +test('ParsePath rejects malformed paths', T => { + T.throws(() => ParsePath('profile.name')) + T.throws(() => ParsePath('$.profile..name')) + T.throws(() => ParsePath('$.9invalid')) +}) + +test('SetValueAtPath replaces an existing value without mutating the original', T => { + const Value = { profile: { name: 'Ada' } } + const Result = SetValueAtPath(Value, '$.profile.name', 'Grace') + + T.deepEqual(Result, { profile: { name: 'Grace' } }) + T.is(Value.profile.name, 'Ada') +}) + +test('SetValueAtPath auto-creates missing intermediate objects and arrays', T => { + const Result = SetValueAtPath<{ A: { B: { C: string }[] } }>({}, '$.A.B[2].C', 'target') + T.deepEqual(Result, { A: { B: [undefined, undefined, { C: 'target' }] } }) +}) + +test('SetValueAtPath supports an updater function based on the old value', T => { + const Value = { count: 1 } + const Result = SetValueAtPath(Value, '$.count', (Old: unknown) => (Old as number) + 1) + T.deepEqual(Result, { count: 2 }) +}) + +test('SetValueAtPath passes the original value and type to updater functions', T => { + const Value = { flag: 1 } + const Result = SetValueAtPath(Value, '$.flag', (Old: unknown) => (typeof Old === 'number' && Old === 1 ? 0 : Old)) + + T.deepEqual(Result, { flag: 0 }) + T.is(Value.flag, 1) +}) + +test('SetValueAtPath passes the property Key and target Path to updater functions', T => { + const Value = { profile: { name: 'Ada' } } + let ReceivedKey: string | number | undefined + let ReceivedPath: string | undefined + + SetValueAtPath(Value, '$.profile.name', (Old: unknown, Key: string | number | undefined, Path: string) => { + ReceivedKey = Key + ReceivedPath = Path + return Old + }) + + T.is(ReceivedKey, 'name') + T.is(ReceivedPath, '$.profile.name') +}) + +test('SetValueAtPath passes the array index as Key to updater functions', T => { + const Value = { list: ['a', 'b'] } + let ReceivedKey: string | number | undefined + + SetValueAtPath(Value, '$.list[1]', (Old: unknown, Key: string | number | undefined) => { + ReceivedKey = Key + return Old + }) + + T.is(ReceivedKey, 1) +}) + +test('SetValueAtPath passes an undefined Key for the root path', T => { + let ReceivedKey: string | number | undefined + + SetValueAtPath({ old: true }, '$', (Old: unknown, Key: string | number | undefined) => { + ReceivedKey = Key + return Old + }) + + T.is(ReceivedKey, undefined) +}) + +test('SetValueAtPath sets a function when it is wrapped as an explicit value', T => { + const Handler = (): string => 'handled' + const Result = SetValueAtPath<{ handler: () => string }>({}, '$.handler', AsPathValue(Handler)) + + T.is(Result.handler, Handler) + T.is(Result.handler(), 'handled') +}) + +test('SetValueAtPath handles escaped-key paths', T => { + const Value = { 'display-name': 'NamuLink' } + const Result = SetValueAtPath(Value, '$[\'display-name\']', 'Renamed') + T.deepEqual(Result, { 'display-name': 'Renamed' }) +}) + +test('SetValueAtPath replaces the whole root when given the root path', T => { + T.deepEqual(SetValueAtPath({ old: true }, '$', { fresh: true }), { fresh: true }) +}) + +test('SetValueAtPath followed by MatchSchema on the produced path round-trips', T => { + const Value = { profile: { name: 'Ada' }, tags: ['wiki'] } + const Schema = { profile: { name: /^Ada$/ } } + const [Path] = MatchSchema(Value, Schema) + + const Updated = SetValueAtPath(Value, Path, 'Grace') + T.deepEqual(Updated, { profile: { name: 'Grace' }, tags: ['wiki'] }) +}) + +test('DeleteValueAtPath removes an object property without mutating the original', T => { + const Value = { profile: { name: 'Ada', role: 'admin' } } + const Result = DeleteValueAtPath(Value, '$.profile.role') + + T.deepEqual(Result, { profile: { name: 'Ada' } }) + T.is(Value.profile.role, 'admin') +}) + +test('DeleteValueAtPath splices out an array element, shifting later indices', T => { + const Value = { tags: ['a', 'b', 'c'] } + const Result = DeleteValueAtPath(Value, '$.tags[1]') + + T.deepEqual(Result, { tags: ['a', 'c'] }) + T.deepEqual(Value.tags, ['a', 'b', 'c']) +}) + +test('DeleteValueAtPath is a no-op when an intermediate path segment does not exist', T => { + const Value = { profile: { name: 'Ada' } } + T.deepEqual(DeleteValueAtPath(Value, '$.missing.name'), Value) +}) \ No newline at end of file diff --git a/testunit/tsconfig.json b/testunit/tsconfig.json new file mode 100644 index 0000000..6d99093 --- /dev/null +++ b/testunit/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "tests/**/*.test.ts", + "VM.d.ts" + ], + "compilerOptions": { + "types": ["node"], + "rootDir": "../", + "outDir": "../dist/", + "declaration": true, + "skipLibCheck": true + } +} \ No newline at end of file diff --git a/userscript/source/dom-await.ts b/userscript/source/dom-await.ts deleted file mode 100644 index 208bcfc..0000000 --- a/userscript/source/dom-await.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function WaitForElement(Selector: string, Root: HTMLElement | Document = document.documentElement): Promise { - return new Promise((Resolve) => { - const Found = Root.querySelector(Selector) - - if (Found && Found instanceof HTMLElement) { - Resolve(Found) - return - } - - const Observer = new MutationObserver(() => { - const El = Root.querySelector(Selector) - - if (El && El instanceof HTMLElement) { - Observer.disconnect() - Resolve(El) - } - }) - - Observer.observe(Root, { - subtree: true, - childList: true, - attributes: true - }) - }) -} \ No newline at end of file diff --git a/userscript/source/index.ts b/userscript/source/index.ts index 687434f..481c208 100644 --- a/userscript/source/index.ts +++ b/userscript/source/index.ts @@ -12,193 +12,94 @@ type unsafeWindow = typeof window // eslint-disable-next-line @typescript-eslint/naming-convention declare const unsafeWindow: unsafeWindow +import { DeleteValueAtPath, MatchValueSchema, ParsePath, SetValueAtPath, type ValueSchema } from './startrick.js' + const Win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window const UserscriptName = 'NamuLink' -import { AttachVueSettledEvents } from './vuejsawait.js' -import { WaitForElement } from './dom-await.js' -import { CreateOcrWorkerClient } from './ocr-client.js' - -// eslint-disable-next-line @typescript-eslint/naming-convention -declare const __OCR_WORKER_CODE__: string - // BUILD:START -const OriginalReflectApply = Win.Reflect.apply - -const PL2PromiseThenRegexs: RegExp[][] = [[ - /function *[A-Za-z0-9]+ *\([A-Za-z0-9]+ * *\) *{ *function *[A-Za-z0-9]+ *\( *[a-zA-Z]+ *, *[A-Za-z]+ *\) *{ *return *[A-Za-z0-9]+ *\( */, - /{ *return *[A-Za-z0-9]+ *\( *[a-zA-Z]+[- ]*0x[a-f0-9]+ *, *[a-zA-Z]+ *\) *; *\} *[A-Za-z0-9]+ *\( *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+/, - /\( *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *\( *0x[a-f0-9]+ *, *0x[a-f0-9]+ *\) *, *[A-Za-z0-9]+ *\) *;/ -]] - -Win.Promise.prototype.then = new Proxy(Win.Promise.prototype.then, { - apply(Target: typeof Promise.prototype.then, ThisArg: Promise, Args: Parameters) { - if (typeof Args[0] !== 'function' || typeof Args[1] !== 'function') { - return OriginalReflectApply(Target, ThisArg, Args) - } - const Stringified: [string, string] = [String(Args[0]), String(Args[1])] - if (Stringified.every(Str => PL2PromiseThenRegexs.filter(Regexs => Regexs.filter(Regex => Regex.test(Str)).length === Regexs.length).length === 1)) { - console.debug(`[${UserscriptName}] Detected PL2 Promise.then`, Stringified, Args) - setTimeout(() => { - let Targeted = [...document.querySelectorAll('#app div[class] div[class] ~ div[class]')].filter(Ele => Ele instanceof HTMLElement) - Targeted = Targeted.filter(Ele => parseFloat(getComputedStyle(Ele).getPropertyValue('margin-bottom')) >= 12.5) - Targeted = Targeted.filter(Ele => Ele.innerText.trim().length === 0) - Targeted = Targeted.filter(Ele => [...Ele.querySelectorAll('*')].filter(Child => Child instanceof HTMLElement).some(Child => { - const Height = Child.getBoundingClientRect().height - return Height > 0 && Height <= 5 - })) - console.debug(`[${UserscriptName}] Detected PL2 Promise.then Targeted`, Targeted) - Targeted.forEach(Ele => { - Ele.style.setProperty('display', 'none', 'important') +const PLInitTracking = /\[?\[\[\[( *null *,)? *\\? *" *! *\/jump\/[a-zA-Z0-9\/=\\+]+ *\\? *" *, *.+[\[\[ *null *, *\\? *" *! *\/jump\/[a-zA-Z0-9\/=\\+]+ *\\? *" *, *.+\/\/i\.namu.wiki\/i\// + +const PLSSRImage = /\/\/i\.namu\.wiki\/i\/[a-zA-Z0-9-_]+\.[a-z]{3,4}/ + +Win.Reflect.set = new Proxy(Win.Reflect.set, { + apply(Target: typeof Reflect.set, ThisArg: Set, ArgArray: Parameters) { + // Property names are randomized, so only the set of values (regardless of key/order) is checked. + const PLInitSchema = [ + /[0-9]{8,12}/, + /[0-9]{8,12}/, + /[0-9]{8,12}/, + /[0-9]{8,12}/, + /[0-9]{1,3}\.[0-9]{12,20}/, + /[a-zA-Z0-9\/=\\+]{20,}/, + /^[01]$/, + /^[01]$/, + /^[01]$/, + /^[01]$/, + /^[01]$/, + PLInitTracking + ] + + const PLSSRSchema: ValueSchema[] = [[ + PLSSRImage, + PLSSRImage, + PLSSRImage, + PLSSRImage, + PLSSRImage + ], [[ + /[a-z0-9]{4,6}/ + ], [ + /[a-z0-9]{4,6}/ + ]], [ + PLSSRImage, + PLSSRImage + ]] + + for (let I = 0; I < ArgArray.length; I++) { + const Arg = ArgArray[I] + + let Matches: string[] = MatchValueSchema(Arg, PLInitSchema, { Exact: false }) + if (Matches.length !== 0) { + console.debug(`${UserscriptName} detected a potential PLSchema match at argument index ${I} and matches:`, Matches, Arg) + let ModifiedArg = ArgArray.map((Value, Index) => { + if (Index === I) return SetValueAtPath(Value, Matches[0], (OldValue: unknown, Key: string | number | undefined, Path: string) => { + switch (true) { + case typeof OldValue === 'string' && OldValue === '1' && Key === 'enable_ads': + return '0' + case typeof OldValue === 'number' && OldValue === 1 && Key === 'enable_ads': + return 0 + case typeof OldValue === 'string' && PLInitTracking.test(OldValue): + return '' + case typeof OldValue === 'string' && /[a-zA-Z0-9\/=\\+]{20,}/.test(OldValue): + return '' + default: + return OldValue + } + }) + return Value }) - }, 250) - return - } - return OriginalReflectApply(Target, ThisArg, Args) - } -}) - -const ArticleHTMLElement = await WaitForElement('#app', Win.document) -const EventName = 'vue:settled' -const ChangeEventName = 'vue:change' -const UrlChangeEventName = 'vue:url-changed' -const UrlBlackBlankEventName = 'vue:black-blank' -AttachVueSettledEvents(ArticleHTMLElement, { - QuietMs: 75, - EventName: EventName, - ChangeEventName: ChangeEventName, - UrlChange: UrlChangeEventName, - BlackBlank: UrlBlackBlankEventName -}) - -const OCRInstance = CreateOcrWorkerClient(Win, new Worker(URL.createObjectURL(new Blob([__OCR_WORKER_CODE__], { type: 'application/javascript' })))) + return Reflect.apply(Target, ThisArg, ModifiedArg) + } -async function ExecuteOCR(Targeted: HTMLElement[]) { - const NextTargeted = [] - for (const Parent of Targeted) { - const CandidateChildren = [...Parent.querySelectorAll('*')] - .filter(Child => Child instanceof HTMLElement) - .filter(Child => - Child instanceof HTMLImageElement || - getComputedStyle(Child).backgroundImage !== 'none' - ).filter(Child => parseFloat(getComputedStyle(Child).getPropertyValue('width')) >= 5 && parseFloat(getComputedStyle(Child).getPropertyValue('height')) >= 5) - .filter(Child => parseFloat(getComputedStyle(Child).getPropertyValue('width')) <= 50 && parseFloat(getComputedStyle(Child).getPropertyValue('height')) <= 50) - let MatchedCount = 0 - for (const Child of CandidateChildren) { - const Result = await OCRInstance.DetectFromElement(Child, { - ScoreThreshold: 0.32 + Matches = MatchValueSchema(Arg, PLSSRSchema, { Exact: false }) + if (Matches.length !== 0) { + console.debug(`${UserscriptName} detected a potential PLSSR schema match at argument index ${I} and matches:`, Matches, Arg) + let ModifiedArg = ArgArray.map((Value, Index) => { + if (Index === I) return SetValueAtPath(Value, Matches[0], (OldValue: unknown, Key: string | number | undefined, Path: string) => { + switch (true) { + case typeof OldValue === 'boolean': + return false + default: + return undefined + } + }) + return Value }) - if (Result !== null) { - MatchedCount += 1 - } - if (MatchedCount >= 1) { - NextTargeted.push(Parent) - break - } + return Reflect.apply(Target, ThisArg, ModifiedArg) } } - return NextTargeted -} -function AllParents(Ele: HTMLElement): Set { - let SetHTMLElement = new Set([Ele]) - for (let I = 0;; I++) { - let Upper = [...SetHTMLElement][I].parentElement - if (Upper === null) { - break - } - SetHTMLElement.add(Upper) + return Reflect.apply(Target, ThisArg, ArgArray) } - return SetHTMLElement -} - -async function Handler(EventParameter: Event) { - let Targeted = [...document.querySelectorAll('#app div[class] div[class] ~ div[class]')].filter(Ele => Ele instanceof HTMLElement) - Targeted = Targeted.filter(Ele => - parseFloat(getComputedStyle(Ele).getPropertyValue('padding-top')) >= 20 || - parseFloat(getComputedStyle(Ele).getPropertyValue('margin-top')) >= 20 || - parseFloat(getComputedStyle(Ele).getPropertyValue('margin-bottom')) >= 12.5 - ) - Targeted = Targeted.filter(Ele => { - let Children = [...Ele.querySelectorAll('*')].filter(Child => Child instanceof HTMLElement) - // non-HTMLTableElement - if (Children.filter(Child => - parseFloat(getComputedStyle(Child).getPropertyValue('padding-top')) >= 5 && - parseFloat(getComputedStyle(Child).getPropertyValue('border-bottom-width')) >= 0.1 - ).length === 1) return true - // HTMLTableElement - return Children.filter(Child => (Child instanceof HTMLTableElement || Child instanceof HTMLTableCellElement) && - parseFloat(getComputedStyle(Child).getPropertyValue('padding-top')) >= 5 && parseFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')) >= 5).length >= 2 - }) - Targeted = Targeted.filter(Ele => { - let Children = [...Ele.querySelectorAll('*')].filter(Child => Child instanceof HTMLElement) - return !Children.some(Child => { - return parseFloat(getComputedStyle(Child).getPropertyValue('margin-bottom')) >= 10 && parseFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')) >= 1 && parseFloat(getComputedStyle(Child).getPropertyValue('padding-top')) >= 1 && - parseFloat(getComputedStyle(Child).getPropertyValue('border-top-width')) >= 0.25 && parseFloat(getComputedStyle(Child).getPropertyValue('border-bottom-width')) >= 0.25 - }) - }) - Targeted = Targeted.filter(Ele => { - let Children = [...Ele.querySelectorAll('*')].filter(Child => Child instanceof HTMLElement) - Children = Children.filter(Child => parseFloat(getComputedStyle(Child).getPropertyValue('padding-right')) >= 10 && parseFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')) >= 10) - Children = Children.filter(Child => parseFloat(getComputedStyle(Child).getPropertyValue('margin-left')) >= 2.5) - return Children.length === 0 - }) - Targeted = Targeted.filter(Ele => { - if (Ele.getBoundingClientRect().width < 500 && Win.document.body.getBoundingClientRect().width > 500) return false - let Children = [...Ele.querySelectorAll('*[style]')].filter(Child => Child instanceof HTMLElement && Child.style.length > 0) - return Children.filter(Child => { - if (!(Child instanceof HTMLElement)) return false - const ComputedStyle = getComputedStyle(Child) - const MissingCount = [...Child.style].filter(Property => { - const InlineValue = Child.style.getPropertyValue(Property).trim() - const ComputedValue = ComputedStyle.getPropertyValue(Property).trim() - return InlineValue !== ComputedValue - }).length - return MissingCount <= 1 - }).length < 10 - }) - Targeted = await ExecuteOCR(Targeted) - Targeted.forEach(Ele => Targeted.push(...new Set([...Ele.querySelectorAll('*')].filter(Child => Child instanceof HTMLElement)))) - Targeted = [...new Set(Targeted)] - let RealTargeted = Targeted.filter(Ele => parseFloat(getComputedStyle(Ele).getPropertyValue('padding-left')) >= 5 && parseFloat(getComputedStyle(Ele).getPropertyValue('border-right-width')) >= 0.1) - console.debug(`[${UserscriptName}] ${EventParameter.type} RealTargeted`, RealTargeted, EventParameter) - RealTargeted.forEach(Ele => { - Ele.style.setProperty('display', 'none', 'important') - }) - let RealTabletTargeted = Targeted.filter(Ele => { - if (!(Ele instanceof HTMLElement) || !(Ele instanceof HTMLTableElement)) return false - let Children = [...Ele.querySelectorAll('*')].filter(Child => Child instanceof HTMLElement) - return Children.some(Child => parseFloat(getComputedStyle(Child).getPropertyValue('padding-top')) >= 5 && parseFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')) >= 5) - }) - console.debug(`[${UserscriptName}] ${EventParameter.type} RealTabletTargeted`, RealTabletTargeted, EventParameter) - RealTabletTargeted.forEach(Ele => { - Ele.style.setProperty('display', 'none', 'important') - }) - - // leftover - const PlaceHolderCandidated: Set = new Set([...RealTargeted, ...RealTabletTargeted]) - PlaceHolderCandidated.forEach(PlaceHolder => { - let Parents = [...AllParents(PlaceHolder)].filter(Ele => Ele.innerText.trim().length === 0) - Parents.forEach(Ele => PlaceHolderCandidated.add(Ele)) - }) - console.debug(`[${UserscriptName}] ${EventParameter.type} PlaceHolderCandidated`, PlaceHolderCandidated, EventParameter); - [...PlaceHolderCandidated].forEach(Ele => { - Ele.style.setProperty('display', 'none', 'important') - }) -} - -ArticleHTMLElement.addEventListener('vue:settled', (EventParameter) => Handler(EventParameter)) -ArticleHTMLElement.addEventListener('vue:url-changed', (EventParameter) => setTimeout(() => Handler(EventParameter), 250)) -ArticleHTMLElement.addEventListener('vue:black-blank', (EventParameter) => setTimeout(() => Handler(EventParameter), 1500)) - -// init Naver Nanum fonts -const FontAddr = [ - 'https://fonts.googleapis.com/css2?family=Nanum Gothic&display=swap', -] -FontAddr.forEach(Addr => { - const Link = Win.document.createElement('link') - Link.rel = 'stylesheet' - Link.href = Addr - Win.document.head.appendChild(Link) }) \ No newline at end of file diff --git a/userscript/source/ocr-client.ts b/userscript/source/ocr-client.ts deleted file mode 100644 index a7d146d..0000000 --- a/userscript/source/ocr-client.ts +++ /dev/null @@ -1,548 +0,0 @@ -import type { MatchResult, WorkerDetectRequest, WorkerResponse } from './ocr-types.js' - -type DetectElementOptions = { - FontCandidates?: readonly string[] - ScoreThreshold?: number -} - -type DetectSourceOptions = DetectElementOptions & { - HostElement: HTMLElement - SourceUrl: string -} - -type SourceResponseData = { - BlobData: Blob - ContentTypeHeader: string | null -} - -type OcrClientCache = { - XhrResponses: Map - PendingXhrResponses: Map> - ImageDataValues: Map - PendingImageDataValues: Map> - OcrResults: Map - PendingOcrResults: Map> -} - -function ParseBackgroundImageUrl(BackgroundImage: string): string | null { - const Trimmed = BackgroundImage.trim() - if (!Trimmed || Trimmed === 'none') return null - - const Match = Trimmed.match(/^url\((.*)\)$/i) - if (!Match) return null - - let Inner = Match[1].trim() - if ((Inner.startsWith('"') && Inner.endsWith('"')) || (Inner.startsWith('\'') && Inner.endsWith('\''))) { - Inner = Inner.slice(1, -1) - } - return Inner -} - -function GetElementEffectiveBackgroundColor(BrowserWindow: typeof window, Element: HTMLElement): string { - let Node: HTMLElement | null = Element - - while (Node) { - const Background = BrowserWindow.getComputedStyle(Node).backgroundColor - if (Background && Background !== 'transparent' && Background !== 'rgba(0, 0, 0, 0)') { - return Background - } - Node = Node.parentElement - } - - return 'rgb(255, 255, 255)' -} - -function GetBackgroundColorCandidates(BrowserWindow: typeof window, Element: HTMLElement, FallbackBackground: string): string[] { - const Candidates = new Set() - - function AddCandidate(BackgroundColor: string | null | undefined): void { - if (!BackgroundColor) return - if (BackgroundColor === 'transparent' || BackgroundColor === 'rgba(0, 0, 0, 0)') return - Candidates.add(BackgroundColor) - } - - AddCandidate(FallbackBackground) - - const ElementStyle = BrowserWindow.getComputedStyle(Element) - AddCandidate(ElementStyle.backgroundColor) - AddCandidate(ElementStyle.color) - AddCandidate(BrowserWindow.getComputedStyle(BrowserWindow.document.documentElement).backgroundColor) - - if (BrowserWindow.document.body) { - const BodyStyle = BrowserWindow.getComputedStyle(BrowserWindow.document.body) - AddCandidate(BodyStyle.backgroundColor) - AddCandidate(BodyStyle.color) - } - - AddCandidate('rgb(255, 255, 255)') - AddCandidate('rgb(0, 0, 0)') - - return [...Candidates] -} - -function ResolveElementSource(BrowserWindow: typeof window, Element: HTMLElement): string | null { - if (Element instanceof BrowserWindow.HTMLImageElement) { - const ImageSource = Element.currentSrc || Element.src || '' - return ImageSource || null - } - - const BackgroundImage = BrowserWindow.getComputedStyle(Element).backgroundImage - return ParseBackgroundImageUrl(BackgroundImage) -} - -function CreateOcrClientCache(): OcrClientCache { - return { - XhrResponses: new Map(), - PendingXhrResponses: new Map(), - ImageDataValues: new Map(), - PendingImageDataValues: new Map(), - OcrResults: new Map(), - PendingOcrResults: new Map(), - } -} - -function ClearOcrClientCache(Cache: OcrClientCache): void { - Cache.XhrResponses.clear() - Cache.PendingXhrResponses.clear() - Cache.ImageDataValues.clear() - Cache.PendingImageDataValues.clear() - Cache.OcrResults.clear() - Cache.PendingOcrResults.clear() -} - -function NormalizeSourceUrl(BrowserWindow: typeof window, SourceUrl: string): string { - try { - return new BrowserWindow.URL(SourceUrl, BrowserWindow.location.href).href - } catch { - return SourceUrl - } -} - -function CreateCacheKey(Parts: readonly unknown[]): string { - return JSON.stringify(Parts) -} - -function CreateBitmapImageDataCacheKey(SourceUrl: string): string { - return CreateCacheKey(['bitmap-image-data', SourceUrl]) -} - -function CreateSvgImageDataCacheKey( - BrowserWindow: typeof window, - HostElement: HTMLElement, - SourceUrl: string, -): string { - const { Width, Height } = GetRasterSize(BrowserWindow, HostElement) - return CreateCacheKey(['svg-image-data', SourceUrl, Width, Height]) -} - -function CreateOcrResultCacheKey( - SourceUrl: string, - ImageDataValue: ImageData, - BackgroundCandidates: readonly string[], - Options?: DetectElementOptions, -): string { - return CreateCacheKey([ - 'ocr-result', - SourceUrl, - ImageDataValue.width, - ImageDataValue.height, - BackgroundCandidates, - Options?.FontCandidates ?? null, - Options?.ScoreThreshold ?? 0.32, - ]) -} - -export function CreateOcrWorkerClient(BrowserWindow: typeof window, WorkerInstance: Worker) { - let RequestSequence = 0 - const Pending = new Map void, Reject: (Reason?: unknown) => void }>() - const Cache = CreateOcrClientCache() - - WorkerInstance.addEventListener('message', (Event: MessageEvent) => { - const Message = Event.data - if (!Message || !('RequestId' in Message)) return - - const PendingRequest = Pending.get(Message.RequestId) - if (!PendingRequest) return - Pending.delete(Message.RequestId) - - if (Message.Kind === 'detect-result') { - PendingRequest.Resolve(Message.Result) - return - } - - PendingRequest.Reject(new Error(Message.Error)) - }) - - function PostDetect(Request: Omit): Promise { - const RequestId = `ocr-${Date.now()}-${RequestSequence++}` - const Message: WorkerDetectRequest = { - Kind: 'detect', - RequestId, - ...Request, - } - - return new Promise((Resolve, Reject) => { - Pending.set(RequestId, { Resolve, Reject }) - WorkerInstance.postMessage(Message) - }) - } - - function DetectFromImageDataWithCache( - SourceUrl: string, - ImageDataValue: ImageData, - BackgroundCandidates: string[], - Options?: DetectElementOptions, - ): Promise { - const CacheKey = CreateOcrResultCacheKey(SourceUrl, ImageDataValue, BackgroundCandidates, Options) - if (Cache.OcrResults.has(CacheKey)) { - return Promise.resolve(Cache.OcrResults.get(CacheKey) ?? null) - } - - const ExistingPendingResult = Cache.PendingOcrResults.get(CacheKey) - if (ExistingPendingResult) return ExistingPendingResult - - const PendingResult = PostDetect({ - ImageData: ImageDataValue, - BackgroundCandidates, - FontCandidates: Options?.FontCandidates, - ScoreThreshold: Options?.ScoreThreshold, - }).then((Result) => { - Cache.OcrResults.set(CacheKey, Result) - return Result - }).finally(() => { - Cache.PendingOcrResults.delete(CacheKey) - }) - - Cache.PendingOcrResults.set(CacheKey, PendingResult) - return PendingResult - } - - async function DetectFromElement(Element: HTMLElement, Options?: DetectElementOptions): Promise { - const SourceUrl = ResolveElementSource(BrowserWindow, Element) - if (!SourceUrl) return null - - const NormalizedSourceUrl = NormalizeSourceUrl(BrowserWindow, SourceUrl) - const ImageDataValue = await LoadImageDataFromSourceUrl(BrowserWindow, Element, NormalizedSourceUrl, Cache) - if (!ImageDataValue) return null - - const FallbackBackground = GetElementEffectiveBackgroundColor(BrowserWindow, Element) - const BackgroundCandidates = GetBackgroundColorCandidates(BrowserWindow, Element, FallbackBackground) - - return DetectFromImageDataWithCache(NormalizedSourceUrl, ImageDataValue, BackgroundCandidates, Options) - } - - async function DetectFromSource(Options: DetectSourceOptions): Promise { - const NormalizedSourceUrl = NormalizeSourceUrl(BrowserWindow, Options.SourceUrl) - const ImageDataValue = await LoadImageDataFromSourceUrl( - BrowserWindow, - Options.HostElement, - NormalizedSourceUrl, - Cache, - ) - if (!ImageDataValue) return null - - const FallbackBackground = GetElementEffectiveBackgroundColor(BrowserWindow, Options.HostElement) - const BackgroundCandidates = GetBackgroundColorCandidates(BrowserWindow, Options.HostElement, FallbackBackground) - - return DetectFromImageDataWithCache(NormalizedSourceUrl, ImageDataValue, BackgroundCandidates, Options) - } - - return { - DetectFromElement, - DetectFromSource, - Terminate(): void { - for (const PendingRequest of Pending.values()) { - PendingRequest.Reject(new Error('OCR worker terminated')) - } - Pending.clear() - ClearOcrClientCache(Cache) - WorkerInstance.terminate() - }, - } -} - -function IsSvgDataUrl(SourceUrl: string): boolean { - return /^data:image\/svg\+xml(?:[;,]|$)/i.test(SourceUrl) -} - -function DecodeBase64Utf8(BrowserWindow: typeof window, Base64Text: string): string { - const Binary = BrowserWindow.atob(Base64Text) - const Bytes = new Uint8Array(Binary.length) - - for (let Index = 0; Index < Binary.length; Index++) { - Bytes[Index] = Binary.charCodeAt(Index) - } - - return new TextDecoder().decode(Bytes) -} - -function DecodeSvgDataUrl(BrowserWindow: typeof window, SourceUrl: string): string { - const CommaIndex = SourceUrl.indexOf(',') - if (CommaIndex < 0) throw new Error('Invalid SVG data URL') - - const Header = SourceUrl.slice(0, CommaIndex).toLowerCase() - const Payload = SourceUrl.slice(CommaIndex + 1) - - if (Header.includes(';base64')) { - return DecodeBase64Utf8(BrowserWindow, Payload) - } - - return decodeURIComponent(Payload) -} - -function PrepareSvgMarkupForRasterize( - BrowserWindow: typeof window, - SvgMarkup: string, - Width: number, - Height: number, -): string { - const Parser = new BrowserWindow.DOMParser() - const XmlDocument = Parser.parseFromString(SvgMarkup, 'image/svg+xml') - - if (XmlDocument.querySelector('parsererror')) { - throw new Error('Failed to parse SVG markup') - } - - const SvgElement = XmlDocument.documentElement - if (!SvgElement || SvgElement.nodeName.toLowerCase() !== 'svg') { - throw new Error('SVG root element not found') - } - - if (!SvgElement.getAttribute('xmlns')) { - SvgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg') - } - - if (!SvgElement.getAttribute('width')) { - SvgElement.setAttribute('width', String(Width)) - } - - if (!SvgElement.getAttribute('height')) { - SvgElement.setAttribute('height', String(Height)) - } - - return new BrowserWindow.XMLSerializer().serializeToString(XmlDocument) -} - -function WaitForImageLoad(ImageElement: HTMLImageElement): Promise { - if (ImageElement.complete && ImageElement.naturalWidth > 0) { - return Promise.resolve() - } - - return new Promise((Resolve, Reject) => { - function Cleanup(): void { - ImageElement.removeEventListener('load', OnLoad) - ImageElement.removeEventListener('error', OnError) - } - - function OnLoad(): void { - Cleanup() - Resolve() - } - - function OnError(): void { - Cleanup() - Reject(new Error('Failed to load SVG image')) - } - - ImageElement.addEventListener('load', OnLoad) - ImageElement.addEventListener('error', OnError) - }) -} - -async function LoadImageElement(BrowserWindow: typeof window, SourceUrl: string): Promise { - const ImageElement = new BrowserWindow.Image() - ImageElement.decoding = 'async' - ImageElement.src = SourceUrl - - try { - await ImageElement.decode() - if (ImageElement.naturalWidth > 0) { - return ImageElement - } - } catch { - } - - await WaitForImageLoad(ImageElement) - return ImageElement -} - -function IsSvgMimeType(MimeType: string | null): boolean { - return typeof MimeType === 'string' && /^image\/svg\+xml(?:\s*;|$)/i.test(MimeType) -} - -function GetRasterSize(BrowserWindow: typeof window, HostElement: HTMLElement): { Width: number, Height: number } { - const Rect = HostElement.getBoundingClientRect() - const Scale = Math.max(1, BrowserWindow.devicePixelRatio || 1) - - return { - Width: Math.max(1, Math.round((Rect.width || HostElement.clientWidth || 96) * Scale)), - Height: Math.max(1, Math.round((Rect.height || HostElement.clientHeight || 32) * Scale)), - } -} - -async function RasterizeSvgMarkupToImageData( - BrowserWindow: typeof window, - HostElement: HTMLElement, - SvgMarkup: string, -): Promise { - const { Width, Height } = GetRasterSize(BrowserWindow, HostElement) - const PreparedSvgMarkup = PrepareSvgMarkupForRasterize(BrowserWindow, SvgMarkup, Width, Height) - - const SvgBlobUrl = BrowserWindow.URL.createObjectURL( - new BrowserWindow.Blob([PreparedSvgMarkup], { type: 'image/svg+xml' }) - ) - - try { - const ImageElement = await LoadImageElement(BrowserWindow, SvgBlobUrl) - - const Canvas = BrowserWindow.document.createElement('canvas') - Canvas.width = Width - Canvas.height = Height - - const Context2D = Canvas.getContext('2d', { willReadFrequently: true }) - if (!Context2D) throw new Error('2D context unavailable') - - Context2D.clearRect(0, 0, Width, Height) - Context2D.drawImage(ImageElement, 0, 0, Width, Height) - - return Context2D.getImageData(0, 0, Width, Height) - } finally { - BrowserWindow.URL.revokeObjectURL(SvgBlobUrl) - } -} - -async function RasterizeBitmapBlobToImageData( - BrowserWindow: typeof window, - BlobData: Blob, -): Promise { - const Bitmap = await BrowserWindow.createImageBitmap(BlobData) - - try { - const Canvas = BrowserWindow.document.createElement('canvas') - Canvas.width = Bitmap.width - Canvas.height = Bitmap.height - - const Context2D = Canvas.getContext('2d', { willReadFrequently: true }) - if (!Context2D) throw new Error('2D context unavailable') - - Context2D.drawImage(Bitmap, 0, 0) - return Context2D.getImageData(0, 0, Canvas.width, Canvas.height) - } finally { - Bitmap.close() - } -} - -async function LoadImageDataFromSourceUrl( - BrowserWindow: typeof window, - HostElement: HTMLElement, - SourceUrl: string, - Cache: OcrClientCache, -): Promise { - if (IsSvgDataUrl(SourceUrl)) { - const ImageDataCacheKey = CreateSvgImageDataCacheKey(BrowserWindow, HostElement, SourceUrl) - return await LoadCachedImageData(Cache, ImageDataCacheKey, async () => { - const SvgMarkup = DecodeSvgDataUrl(BrowserWindow, SourceUrl) - return await RasterizeSvgMarkupToImageData(BrowserWindow, HostElement, SvgMarkup) - }) - } - - const BitmapImageDataCacheKey = CreateBitmapImageDataCacheKey(SourceUrl) - const CachedBitmapImageData = GetCachedImageData(Cache, BitmapImageDataCacheKey) - if (CachedBitmapImageData) return CachedBitmapImageData - - const SvgImageDataCacheKey = CreateSvgImageDataCacheKey(BrowserWindow, HostElement, SourceUrl) - const CachedSvgImageData = GetCachedImageData(Cache, SvgImageDataCacheKey) - if (CachedSvgImageData) return CachedSvgImageData - - const ResponseData = await LoadCachedSourceResponseData(Cache, SourceUrl) - if (!ResponseData) return null - - if (IsSvgMimeType(ResponseData.BlobData.type) || IsSvgMimeType(ResponseData.ContentTypeHeader)) { - return await LoadCachedImageData(Cache, SvgImageDataCacheKey, async () => { - const SvgMarkup = await ResponseData.BlobData.text() - return await RasterizeSvgMarkupToImageData(BrowserWindow, HostElement, SvgMarkup) - }) - } - - return await LoadCachedImageData(Cache, BitmapImageDataCacheKey, async () => { - return await RasterizeBitmapBlobToImageData(BrowserWindow, ResponseData.BlobData) - }) -} - -function GetCachedImageData(Cache: OcrClientCache, CacheKey: string): ImageData | null { - if (!Cache.ImageDataValues.has(CacheKey)) return null - return Cache.ImageDataValues.get(CacheKey) ?? null -} - -function LoadCachedImageData( - Cache: OcrClientCache, - CacheKey: string, - Loader: () => Promise, -): Promise { - const CachedImageData = GetCachedImageData(Cache, CacheKey) - if (CachedImageData) return Promise.resolve(CachedImageData) - - const ExistingPendingImageData = Cache.PendingImageDataValues.get(CacheKey) - if (ExistingPendingImageData) return ExistingPendingImageData - - const PendingImageData = Loader().then((ImageDataValue) => { - Cache.ImageDataValues.set(CacheKey, ImageDataValue) - return ImageDataValue - }).finally(() => { - Cache.PendingImageDataValues.delete(CacheKey) - }) - - Cache.PendingImageDataValues.set(CacheKey, PendingImageData) - return PendingImageData -} - -function LoadCachedSourceResponseData(Cache: OcrClientCache, SourceUrl: string): Promise { - if (Cache.XhrResponses.has(SourceUrl)) { - return Promise.resolve(Cache.XhrResponses.get(SourceUrl) ?? null) - } - - const ExistingPendingResponseData = Cache.PendingXhrResponses.get(SourceUrl) - if (ExistingPendingResponseData) return ExistingPendingResponseData - - const PendingResponseData = LoadSourceResponseData(SourceUrl).then((ResponseData) => { - if (ResponseData) Cache.XhrResponses.set(SourceUrl, ResponseData) - return ResponseData - }).finally(() => { - Cache.PendingXhrResponses.delete(SourceUrl) - }) - - Cache.PendingXhrResponses.set(SourceUrl, PendingResponseData) - return PendingResponseData -} - -function LoadSourceResponseData(SourceUrl: string): Promise { - return new Promise((Resolve) => { - GM.xmlHttpRequest({ - url: SourceUrl, - method: 'GET', - responseType: 'blob', - onload: (ResponseValue) => { - if (ResponseValue.status < 200 || ResponseValue.status >= 300) { - Resolve(null) - return - } - - const BlobData = ResponseValue.response - if (!(BlobData instanceof Blob)) { - Resolve(null) - return - } - - const ResponseHeaders = typeof ResponseValue.responseHeaders === 'string' - ? ResponseValue.responseHeaders - : '' - const HeaderMatch = ResponseHeaders.match(/^content-type:\s*(.+)$/im) - const ContentTypeHeader = HeaderMatch ? HeaderMatch[1].trim() : null - - Resolve({ BlobData, ContentTypeHeader }) - }, - onerror: () => Resolve(null), - ontimeout: () => Resolve(null), - }) - }) -} diff --git a/userscript/source/ocr-types.ts b/userscript/source/ocr-types.ts deleted file mode 100644 index 0bf8c9f..0000000 --- a/userscript/source/ocr-types.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type TargetLabel = '파워링크' | '광고' | '광고등록' - -export type BoundingBox = { - X: number - Y: number - Width: number - Height: number -} - -export type MatchResult = - | { - Label: TargetLabel - Score: number - Box: BoundingBox - } - | null - -export type WorkerDetectRequest = { - Kind: 'detect' - RequestId: string - ImageData: ImageData - BackgroundCandidates: string[] - FontCandidates?: readonly string[] - ScoreThreshold?: number -} - -export type WorkerDetectSuccessResponse = { - Kind: 'detect-result' - RequestId: string - Result: MatchResult -} - -export type WorkerDetectErrorResponse = { - Kind: 'detect-error' - RequestId: string - Error: string -} - -export type WorkerMessage = WorkerDetectRequest -export type WorkerResponse = WorkerDetectSuccessResponse | WorkerDetectErrorResponse diff --git a/userscript/source/ocr-worker.ts b/userscript/source/ocr-worker.ts deleted file mode 100644 index 71873e4..0000000 --- a/userscript/source/ocr-worker.ts +++ /dev/null @@ -1,516 +0,0 @@ -import type { - BoundingBox, - MatchResult, - TargetLabel, - WorkerDetectRequest, - WorkerDetectSuccessResponse, - WorkerDetectErrorResponse, -} from './ocr-types.js' - -type GrayImage = { - Width: number - Height: number - Data: Uint8ClampedArray -} - -type BinaryImage = { - Width: number - Height: number - Data: Uint8Array -} - -const Targets: readonly TargetLabel[] = ['파워링크', '광고', '광고등록'] as const -const DefaultFontCandidates = [ - 'Pretendard JP, sans-serif', - 'Pretendard, sans-serif', - 'system-ui, sans-serif', - 'Apple SD Gothic Neo, sans-serif', - 'Nanum Gothic, sans-serif', - 'Noto Sans KR, sans-serif', - 'Arial, sans-serif', -] as const - -const TemplateCache = new Map() - -function CreateCanvas(Width: number, Height: number): OffscreenCanvas { - return new OffscreenCanvas(Math.max(1, Math.floor(Width)), Math.max(1, Math.floor(Height))) -} - -function Get2DContext(Canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D { - const Context2D = Canvas.getContext('2d', { willReadFrequently: true }) - if (!Context2D) throw new Error('2D context unavailable') - return Context2D -} - - -// function DrawImageWithBackground( -// Source: CanvasImageSource, -// Width: number, -// Height: number, -// BackgroundCssColor: string, -// ): OffscreenCanvas { -// const Canvas = CreateCanvas(Width, Height) -// const Context2D = Get2DContext(Canvas) -// Context2D.fillStyle = BackgroundCssColor -// Context2D.fillRect(0, 0, Width, Height) -// Context2D.drawImage(Source, 0, 0, Width, Height) -// return Canvas -// } - -function CanvasToGrayImage(Canvas: OffscreenCanvas): GrayImage { - const Context2D = Get2DContext(Canvas) - const { width: Width, height: Height } = Canvas - const Rgba = Context2D.getImageData(0, 0, Width, Height).data - const Gray = new Uint8ClampedArray(Width * Height) - - for (let Index = 0, Pixel = 0; Index < Rgba.length; Index += 4, Pixel++) { - const Red = Rgba[Index] - const Green = Rgba[Index + 1] - const Blue = Rgba[Index + 2] - Gray[Pixel] = Math.round(0.299 * Red + 0.587 * Green + 0.114 * Blue) - } - - return { Width, Height, Data: Gray } -} - -function OtsuThreshold(Gray: GrayImage): number { - const Histogram = new Uint32Array(256) - for (let Index = 0; Index < Gray.Data.length; Index++) Histogram[Gray.Data[Index]]++ - - const Total = Gray.Data.length - let Sum = 0 - for (let Index = 0; Index < 256; Index++) Sum += Index * Histogram[Index] - - let SumBackground = 0 - let WeightBackground = 0 - let MaxVariance = -1 - let Threshold = 127 - - for (let ThresholdIndex = 0; ThresholdIndex < 256; ThresholdIndex++) { - WeightBackground += Histogram[ThresholdIndex] - if (WeightBackground === 0) continue - - const WeightForeground = Total - WeightBackground - if (WeightForeground === 0) break - - SumBackground += ThresholdIndex * Histogram[ThresholdIndex] - const MeanBackground = SumBackground / WeightBackground - const MeanForeground = (Sum - SumBackground) / WeightForeground - const BetweenClassVariance = - WeightBackground * WeightForeground * (MeanBackground - MeanForeground) * (MeanBackground - MeanForeground) - - if (BetweenClassVariance > MaxVariance) { - MaxVariance = BetweenClassVariance - Threshold = ThresholdIndex - } - } - - return Threshold -} - -function BinarizeByContrast(Gray: GrayImage): BinaryImage { - const Threshold = OtsuThreshold(Gray) - let DarkCount = 0 - let LightCount = 0 - - for (let Index = 0; Index < Gray.Data.length; Index++) { - if (Gray.Data[Index] < Threshold) DarkCount++ - else LightCount++ - } - - const TextIsDark = DarkCount < LightCount - const Output = new Uint8Array(Gray.Width * Gray.Height) - - for (let Index = 0; Index < Gray.Data.length; Index++) { - const IsText = TextIsDark ? Gray.Data[Index] < Threshold : Gray.Data[Index] > Threshold - Output[Index] = IsText ? 1 : 0 - } - - return { Width: Gray.Width, Height: Gray.Height, Data: Output } -} - -function Erode3x3(Source: BinaryImage): BinaryImage { - const Output = new Uint8Array(Source.Width * Source.Height) - - for (let Y = 1; Y < Source.Height - 1; Y++) { - for (let X = 1; X < Source.Width - 1; X++) { - let Keep = 1 - for (let DeltaY = -1; DeltaY <= 1 && Keep; DeltaY++) { - for (let DeltaX = -1; DeltaX <= 1; DeltaX++) { - if (Source.Data[(Y + DeltaY) * Source.Width + (X + DeltaX)] === 0) { - Keep = 0 - break - } - } - } - Output[Y * Source.Width + X] = Keep - } - } - - return { Width: Source.Width, Height: Source.Height, Data: Output } -} - -function Dilate3x3(Source: BinaryImage): BinaryImage { - const Output = new Uint8Array(Source.Width * Source.Height) - - for (let Y = 1; Y < Source.Height - 1; Y++) { - for (let X = 1; X < Source.Width - 1; X++) { - let Value = 0 - for (let DeltaY = -1; DeltaY <= 1 && !Value; DeltaY++) { - for (let DeltaX = -1; DeltaX <= 1; DeltaX++) { - if (Source.Data[(Y + DeltaY) * Source.Width + (X + DeltaX)] === 1) { - Value = 1 - break - } - } - } - Output[Y * Source.Width + X] = Value - } - } - - return { Width: Source.Width, Height: Source.Height, Data: Output } -} - -function OpenClose(Source: BinaryImage): BinaryImage { - return Dilate3x3(Erode3x3(Dilate3x3(Source))) -} - -function FindConnectedComponents(Source: BinaryImage, MinArea = 20): BoundingBox[] { - const Visited = new Uint8Array(Source.Width * Source.Height) - const Boxes: BoundingBox[] = [] - const QueueX = new Int32Array(Source.Width * Source.Height) - const QueueY = new Int32Array(Source.Width * Source.Height) - - for (let Y = 0; Y < Source.Height; Y++) { - for (let X = 0; X < Source.Width; X++) { - const Index = Y * Source.Width + X - if (Visited[Index] || Source.Data[Index] === 0) continue - - let Head = 0 - let Tail = 0 - QueueX[Tail] = X - QueueY[Tail] = Y - Tail++ - Visited[Index] = 1 - - let MinX = X - let MinY = Y - let MaxX = X - let MaxY = Y - let Area = 0 - - while (Head < Tail) { - const CurrentX = QueueX[Head] - const CurrentY = QueueY[Head] - Head++ - Area++ - if (CurrentX < MinX) MinX = CurrentX - if (CurrentY < MinY) MinY = CurrentY - if (CurrentX > MaxX) MaxX = CurrentX - if (CurrentY > MaxY) MaxY = CurrentY - - for (let DeltaY = -1; DeltaY <= 1; DeltaY++) { - for (let DeltaX = -1; DeltaX <= 1; DeltaX++) { - if (DeltaX === 0 && DeltaY === 0) continue - const NextX = CurrentX + DeltaX - const NextY = CurrentY + DeltaY - if (NextX < 0 || NextY < 0 || NextX >= Source.Width || NextY >= Source.Height) continue - - const NextIndex = NextY * Source.Width + NextX - if (Visited[NextIndex] || Source.Data[NextIndex] === 0) continue - Visited[NextIndex] = 1 - QueueX[Tail] = NextX - QueueY[Tail] = NextY - Tail++ - } - } - } - - if (Area >= MinArea) { - Boxes.push({ X: MinX, Y: MinY, Width: MaxX - MinX + 1, Height: MaxY - MinY + 1 }) - } - } - } - - return Boxes -} - -function MergeNearbyBoxes(Boxes: BoundingBox[], GapX = 8, GapY = 4): BoundingBox[] { - const Result = [...Boxes] - let Changed = true - - function OverlapsOrNear(A: BoundingBox, B: BoundingBox): boolean { - const AX2 = A.X + A.Width - const AY2 = A.Y + A.Height - const BX2 = B.X + B.Width - const BY2 = B.Y + B.Height - return !( - AX2 + GapX < B.X - || BX2 + GapX < A.X - || AY2 + GapY < B.Y - || BY2 + GapY < A.Y - ) - } - - while (Changed) { - Changed = false - outer: for (let IndexA = 0; IndexA < Result.length; IndexA++) { - for (let IndexB = IndexA + 1; IndexB < Result.length; IndexB++) { - if (!OverlapsOrNear(Result[IndexA], Result[IndexB])) continue - const A = Result[IndexA] - const B = Result[IndexB] - Result[IndexA] = { - X: Math.min(A.X, B.X), - Y: Math.min(A.Y, B.Y), - Width: Math.max(A.X + A.Width, B.X + B.Width) - Math.min(A.X, B.X), - Height: Math.max(A.Y + A.Height, B.Y + B.Height) - Math.min(A.Y, B.Y), - } - Result.splice(IndexB, 1) - Changed = true - break outer - } - } - } - - return Result -} - -function CropBinary(Source: BinaryImage, Box: BoundingBox): BinaryImage { - const Output = new Uint8Array(Box.Width * Box.Height) - for (let Y = 0; Y < Box.Height; Y++) { - for (let X = 0; X < Box.Width; X++) { - Output[Y * Box.Width + X] = Source.Data[(Box.Y + Y) * Source.Width + (Box.X + X)] - } - } - return { Width: Box.Width, Height: Box.Height, Data: Output } -} - -function TrimBinary(Source: BinaryImage): BinaryImage { - let MinX = Source.Width - let MinY = Source.Height - let MaxX = -1 - let MaxY = -1 - - for (let Y = 0; Y < Source.Height; Y++) { - for (let X = 0; X < Source.Width; X++) { - if (Source.Data[Y * Source.Width + X] === 0) continue - if (X < MinX) MinX = X - if (Y < MinY) MinY = Y - if (X > MaxX) MaxX = X - if (Y > MaxY) MaxY = Y - } - } - - if (MaxX < MinX || MaxY < MinY) { - return { Width: 1, Height: 1, Data: new Uint8Array([0]) } - } - - return CropBinary(Source, { X: MinX, Y: MinY, Width: MaxX - MinX + 1, Height: MaxY - MinY + 1 }) -} - -function ResizeBinaryNearest(Source: BinaryImage, Width: number, Height: number): BinaryImage { - const Output = new Uint8Array(Width * Height) - - for (let Y = 0; Y < Height; Y++) { - for (let X = 0; X < Width; X++) { - const SourceX = Math.min(Source.Width - 1, Math.floor((X / Width) * Source.Width)) - const SourceY = Math.min(Source.Height - 1, Math.floor((Y / Height) * Source.Height)) - Output[Y * Width + X] = Source.Data[SourceY * Source.Width + SourceX] - } - } - - return { Width, Height, Data: Output } -} - -function NormalizeBinary(Source: BinaryImage, Size = 64): BinaryImage { - const Trimmed = TrimBinary(Source) - const Side = Math.max(Trimmed.Width, Trimmed.Height) - const Padded = new Uint8Array(Side * Side) - const OffsetX = Math.floor((Side - Trimmed.Width) / 2) - const OffsetY = Math.floor((Side - Trimmed.Height) / 2) - - for (let Y = 0; Y < Trimmed.Height; Y++) { - for (let X = 0; X < Trimmed.Width; X++) { - Padded[(Y + OffsetY) * Side + (X + OffsetX)] = Trimmed.Data[Y * Trimmed.Width + X] - } - } - - return ResizeBinaryNearest({ Width: Side, Height: Side, Data: Padded }, Size, Size) -} - -function XorDistance(Left: BinaryImage, Right: BinaryImage): number { - if (Left.Width !== Right.Width || Left.Height !== Right.Height) { - throw new Error('Image size mismatch') - } - let Different = 0 - for (let Index = 0; Index < Left.Data.length; Index++) { - if (Left.Data[Index] !== Right.Data[Index]) Different++ - } - return Different / Left.Data.length -} - -function GetTemplate(Text: string, FontFamily: string): BinaryImage { - const CacheKey = `${Text}__${FontFamily}` - const Cached = TemplateCache.get(CacheKey) - if (Cached) return Cached - - const Width = 256 - const Height = 96 - const Canvas = CreateCanvas(Width, Height) - const Context2D = Get2DContext(Canvas) - Context2D.fillStyle = 'white' - Context2D.fillRect(0, 0, Width, Height) - let FontSize = Math.floor(Height * 0.72) - - while (FontSize > 8) { - Context2D.clearRect(0, 0, Width, Height) - Context2D.fillStyle = 'white' - Context2D.fillRect(0, 0, Width, Height) - Context2D.fillStyle = 'black' - Context2D.textAlign = 'center' - Context2D.textBaseline = 'middle' - Context2D.font = `700 ${FontSize}px ${FontFamily}` - - const Metrics = Context2D.measureText(Text) - const TextWidth = Metrics.width - const TextHeight = - (Metrics.actualBoundingBoxAscent || FontSize * 0.8) - + (Metrics.actualBoundingBoxDescent || FontSize * 0.2) - - if (TextWidth <= Width * 0.9 && TextHeight <= Height * 0.9) { - Context2D.fillText(Text, Width / 2, Height / 2) - const Gray = CanvasToGrayImage(Canvas) - const Template = NormalizeBinary(BinarizeByContrast(Gray)) - TemplateCache.set(CacheKey, Template) - return Template - } - FontSize-- - } - - Context2D.font = `700 12px ${FontFamily}` - Context2D.fillStyle = 'black' - Context2D.textAlign = 'center' - Context2D.textBaseline = 'middle' - Context2D.fillText(Text, Width / 2, Height / 2) - const Template = NormalizeBinary(BinarizeByContrast(CanvasToGrayImage(Canvas))) - TemplateCache.set(CacheKey, Template) - return Template -} - -function ScoreRegionAgainstTarget(Region: BinaryImage, Target: TargetLabel, FontCandidates: readonly string[]): number { - const NormalizedRegion = NormalizeBinary(Region) - let Best = Number.POSITIVE_INFINITY - for (const FontFamily of FontCandidates) { - const Template = GetTemplate(Target, FontFamily) - const Score = XorDistance(NormalizedRegion, Template) - if (Score < Best) Best = Score - } - return Best -} - -function SelectTextRegions(Binary: BinaryImage): BoundingBox[] { - const Raw = FindConnectedComponents(Binary, 16) - const Merged = MergeNearbyBoxes(Raw, 10, 6) - return Merged.filter((Box) => { - if (Box.Width < 8 || Box.Height < 8) return false - const Ratio = Box.Width / Box.Height - return Ratio > 0.5 && Ratio < 12 - }) -} - -async function DetectFromSource(Request: WorkerDetectRequest): Promise { - const HasTransparency = HasTransparentPixelsInImageData(Request.ImageData) - const BackgroundCandidates = HasTransparency - ? Request.BackgroundCandidates - : Request.BackgroundCandidates.slice(0, 1) - - const FontCandidates = Request.FontCandidates ?? DefaultFontCandidates - const ScoreThreshold = Request.ScoreThreshold ?? 0.32 - let Best: MatchResult = null - - for (const BackgroundColor of BackgroundCandidates) { - const CompositedImageData = CompositeImageDataOnBackground(Request.ImageData, BackgroundColor) - const Gray = ImageDataToGrayImage(CompositedImageData) - const Binary = OpenClose(BinarizeByContrast(Gray)) - const Regions = SelectTextRegions(Binary) - if (Regions.length === 0) continue - - for (const Box of Regions) { - const Region = CropBinary(Binary, Box) - for (const Target of Targets) { - const Score = ScoreRegionAgainstTarget(Region, Target, FontCandidates) - if (!Best || Score < Best.Score) { - Best = { Label: Target, Score, Box } - } - } - } - } - - if (!Best) return null - if (Best.Score > ScoreThreshold) return null - return Best -} - -function ImageDataToGrayImage(Source: ImageData): GrayImage { - const { width: Width, height: Height, data: Rgba } = Source - const Gray = new Uint8ClampedArray(Width * Height) - - for (let Index = 0, Pixel = 0; Index < Rgba.length; Index += 4, Pixel++) { - const Red = Rgba[Index] - const Green = Rgba[Index + 1] - const Blue = Rgba[Index + 2] - Gray[Pixel] = Math.round(0.299 * Red + 0.587 * Green + 0.114 * Blue) - } - - return { Width, Height, Data: Gray } -} - -function HasTransparentPixelsInImageData(Source: ImageData): boolean { - const Rgba = Source.data - - for (let Index = 3; Index < Rgba.length; Index += 4) { - if (Rgba[Index] < 255) return true - } - - return false -} - -function CompositeImageDataOnBackground(Source: ImageData, BackgroundCssColor: string): ImageData { - const Canvas = CreateCanvas(Source.width, Source.height) - const Context2D = Get2DContext(Canvas) - - Context2D.fillStyle = BackgroundCssColor - Context2D.fillRect(0, 0, Source.width, Source.height) - Context2D.putImageData(Source, 0, 0) - - return Context2D.getImageData(0, 0, Source.width, Source.height) -} - -self.addEventListener('message', (Event: MessageEvent) => { - if (Event.origin !== '') return - - void (async () => { - const Message = Event.data - if (!Message || Message.Kind !== 'detect') return - - try { - const Result = await DetectFromSource(Message) - const Response: WorkerDetectSuccessResponse = { - Kind: 'detect-result', - RequestId: Message.RequestId, - Result, - } - self.postMessage(Response) - } catch (ErrorValue) { - const ErrorMessage = ErrorValue instanceof Error ? ErrorValue.message : String(ErrorValue) - const Response: WorkerDetectErrorResponse = { - Kind: 'detect-error', - RequestId: Message.RequestId, - Error: ErrorMessage, - } - self.postMessage(Response) - } - })() -}) - -export {} diff --git a/userscript/source/startrick.ts b/userscript/source/startrick.ts new file mode 100644 index 0000000..872c80d --- /dev/null +++ b/userscript/source/startrick.ts @@ -0,0 +1,233 @@ +type SchemaEntry = { + Path: string + Schema: unknown + Value: unknown +} + +function IsRecord(Value: unknown): Value is Record { + return typeof Value === 'object' && Value !== null +} + +function IsStringifiablePrimitive(Value: unknown): Value is null | undefined | string | number | boolean | bigint | symbol { + return Value === null || (typeof Value !== 'object' && typeof Value !== 'function') +} + +function TestRegExp(Schema: RegExp, Value: unknown): boolean { + if (!IsStringifiablePrimitive(Value)) return false + return new RegExp(Schema.source, Schema.flags).test(String(Value)) +} + +function AddPathSegment(Path: string, Key: string): string { + if (/^(?:[A-Za-z_$][A-Za-z0-9_$]*)$/.test(Key)) return `${Path}.${Key}` + if (/^(?:0|[1-9]\d*)$/.test(Key)) return `${Path}[${Key}]` + return `${Path}['${Key.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}']` +} + +export function MatchSchema(Value: unknown, Schema: T): string[] { + const Matches = new Set() + const Entries: SchemaEntry[] = [{ Path: '$', Schema, Value }] + + while (Entries.length !== 0) { + const Entry = Entries.pop() + if (Entry === undefined) continue + + if (Entry.Schema instanceof RegExp) { + if (TestRegExp(Entry.Schema, Entry.Value)) { + Matches.add(Entry.Path) + } + continue + } + + if (!IsRecord(Entry.Value) || !IsRecord(Entry.Schema)) continue + + const Keys = Object.keys(Entry.Schema) + for (let Index = Keys.length - 1; Index >= 0; Index--) { + const Key = Keys[Index] + if (Object.hasOwn(Entry.Value, Key)) { + Entries.push({ + Path: AddPathSegment(Entry.Path, Key), + Schema: Entry.Schema[Key], + Value: Entry.Value[Key], + }) + } + } + } + + return [...Matches] +} + +export function AddrSchema(Value: unknown, Schema: S): Value is T { + return MatchSchema(Value, Schema).length !== 0 +} + +export type MatchValueSchemaOptions = { + /** Require the value to contain exactly as many properties as there are Schemas (default: false, allows extras). */ + Exact?: boolean +} + +/** A regex or recursively nested collection of value requirements. */ +export type ValueSchema = RegExp | readonly ValueSchema[] + +function MatchNestedSchema(Value: unknown, Schema: ValueSchema): boolean { + if (Schema instanceof RegExp) return TestRegExp(Schema, Value) + if (!Array.isArray(Value)) { + if (!IsRecord(Value)) return Schema.length === 1 && MatchNestedSchema(Value, Schema[0]) + return Object.values(Value).some(Child => MatchNestedSchema(Child, Schema)) + } + + const CandidateValues: unknown[] = Value + const SchemaValues: readonly ValueSchema[] = Schema + const MatchOfValue = new Array(CandidateValues.length).fill(null) + function TryAssign(SchemaIndex: number, Visited: boolean[]): boolean { + for (let ValueIndex = 0; ValueIndex < CandidateValues.length; ValueIndex++) { + if (Visited[ValueIndex] || !MatchNestedDescendant(CandidateValues[ValueIndex], SchemaValues[SchemaIndex])) continue + Visited[ValueIndex] = true + const Owner = MatchOfValue[ValueIndex] + if (Owner === null || TryAssign(Owner, Visited)) { + MatchOfValue[ValueIndex] = SchemaIndex + return true + } + } + return false + } + + for (let SchemaIndex = 0; SchemaIndex < SchemaValues.length; SchemaIndex++) { + if (!TryAssign(SchemaIndex, new Array(CandidateValues.length).fill(false))) return false + } + return true +} + +function MatchNestedDescendant(Value: unknown, Schema: ValueSchema): boolean { + return MatchNestedSchema(Value, Schema) +} + +/** Matches recursively nested value requirements regardless of property names/order, returning matched top-level JSONPaths. */ +export function MatchValueSchema(Value: unknown, Schemas: readonly ValueSchema[], Options: MatchValueSchemaOptions = {}): string[] { + if (!IsRecord(Value)) return [] + + const Entries = Object.entries(Value) + if (Options.Exact ? Entries.length !== Schemas.length : Entries.length < Schemas.length) return [] + + const MatchOfValue = new Array(Entries.length).fill(null) + function TryAssign(SchemaIndex: number, Visited: boolean[]): boolean { + for (let ValueIndex = 0; ValueIndex < Entries.length; ValueIndex++) { + if (Visited[ValueIndex] || !MatchNestedDescendant(Entries[ValueIndex][1], Schemas[SchemaIndex])) continue + Visited[ValueIndex] = true + const Owner = MatchOfValue[ValueIndex] + if (Owner === null || TryAssign(Owner, Visited)) { + MatchOfValue[ValueIndex] = SchemaIndex + return true + } + } + return false + } + + for (let SchemaIndex = 0; SchemaIndex < Schemas.length; SchemaIndex++) { + if (!TryAssign(SchemaIndex, new Array(Entries.length).fill(false))) return [] + } + + return MatchOfValue.reduce((Paths, SchemaIndex, ValueIndex) => { + if (SchemaIndex !== null) Paths.push(AddPathSegment('$', Entries[ValueIndex][0])) + return Paths + }, []) +} + +export function AddrValueSchema(Value: unknown, Schemas: readonly ValueSchema[], Options?: MatchValueSchemaOptions): Value is T { + return MatchValueSchema(Value, Schemas, Options).length !== 0 +} + +const PathSegmentPattern = /\.([A-Za-z_$][A-Za-z0-9_$]*)|\[(0|[1-9]\d*)\]|\['((?:[^'\\]|\\.)*)'\]/y + +/** Parses a JSONPath string produced by {@link AddPathSegment} (e.g. `$.a[0]['b-c']`) back into its key segments. */ +export function ParsePath(Path: string): (string | number)[] { + if (!Path.startsWith('$')) throw new Error(`Invalid JSONPath: ${Path}`) + + const Segments: (string | number)[] = [] + PathSegmentPattern.lastIndex = 1 + while (PathSegmentPattern.lastIndex < Path.length) { + const StartIndex = PathSegmentPattern.lastIndex + const Match = PathSegmentPattern.exec(Path) + if (Match === null || Match.index !== StartIndex) throw new Error(`Invalid JSONPath: ${Path}`) + + if (Match[1] !== undefined) Segments.push(Match[1]) + else if (Match[2] !== undefined) Segments.push(Number(Match[2])) + else Segments.push(Match[3].replaceAll(/\\(.)/g, '$1')) + } + return Segments +} + +function IsPlainObject(Value: unknown): Value is Record { + return IsRecord(Value) && !Array.isArray(Value) +} + +function CloneContainer(Node: unknown, NextKey: string | number): Record | unknown[] { + if (typeof NextKey === 'number') return Array.isArray(Node) ? [...Node] : [] + return IsPlainObject(Node) ? { ...Node } : {} +} + +const PathValueMarker = Symbol('PathValue') + +export type ExplicitPathValue = { + readonly [PathValueMarker]: unknown +} + +/** Marks Value as a literal value, allowing a function to be set without invoking it as an updater. */ +export function AsPathValue(Value: unknown): ExplicitPathValue { + return { [PathValueMarker]: Value } +} + +function IsExplicitPathValue(Value: unknown): Value is ExplicitPathValue { + return IsRecord(Value) && Object.hasOwn(Value, PathValueMarker) +} + +export type PathValueOrUpdater = unknown | ((Old: unknown, Key: string | number | undefined, Path: string) => unknown) + +/** Immutably sets the value at Path, auto-creating missing intermediate objects/arrays (structural sharing elsewhere). */ +export function SetValueAtPath(Root: unknown, Path: string, ValueOrUpdater: PathValueOrUpdater): T { + const Segments = ParsePath(Path) + const TargetKey = Segments.length === 0 ? undefined : Segments[Segments.length - 1] + + function Recurse(Node: unknown, Index: number): unknown { + if (Index === Segments.length) { + if (IsExplicitPathValue(ValueOrUpdater)) return ValueOrUpdater[PathValueMarker] + return typeof ValueOrUpdater === 'function' ? (ValueOrUpdater as (Old: unknown, Key: string | number | undefined, Path: string) => unknown)(Node, TargetKey, Path) : ValueOrUpdater + } + + const Key = Segments[Index] + const Container = CloneContainer(Node, Key) + const OldChild = IsRecord(Node) ? (Node as Record)[Key] : undefined + ;(Container as Record)[Key] = Recurse(OldChild, Index + 1) + return Container + } + + return Recurse(Root, 0) as T +} + +/** Immutably removes the property/element at Path; object keys are deleted, array elements are spliced out. Missing paths are a no-op. */ +export function DeleteValueAtPath(Root: unknown, Path: string): T { + const Segments = ParsePath(Path) + if (Segments.length === 0) throw new Error('Cannot delete the root value') + + function Recurse(Node: unknown, Index: number): unknown { + const Key = Segments[Index] + const IsLast = Index === Segments.length - 1 + + if (typeof Key === 'number') { + if (!Array.isArray(Node) || Key >= Node.length) return Node + const Clone = [...Node] + if (IsLast) Clone.splice(Key, 1) + else Clone[Key] = Recurse(Clone[Key], Index + 1) + return Clone + } + + if (!IsPlainObject(Node) || !Object.hasOwn(Node, Key)) return Node + if (IsLast) { + const Clone = { ...Node } + delete Clone[Key] + return Clone + } + return { ...Node, [Key]: Recurse(Node[Key], Index + 1) } + } + + return Recurse(Root, 0) as T +} \ No newline at end of file diff --git a/userscript/source/vuejsawait.ts b/userscript/source/vuejsawait.ts deleted file mode 100644 index f86d604..0000000 --- a/userscript/source/vuejsawait.ts +++ /dev/null @@ -1,133 +0,0 @@ -export function AttachVueSettledEvents(TargetEl: HTMLElement, Options: { QuietMs?: number; EventName?: string; ChangeEventName?: string; UrlChange?: string; BlackBlank?: string } = {}) { - const QuietMs = Options.QuietMs ?? 120 - const EventName = Options.EventName ?? 'vue:settled' - const ChangeEventName = Options.ChangeEventName ?? 'vue:dom-changed' - const UrlChangeEventName = Options.UrlChange ?? 'vue:url-changed' - const UrlBlackBlankEventName = Options.BlackBlank ?? 'vue:black-blank' - - if (!(TargetEl instanceof HTMLElement)) { - throw new TypeError('TargetEl must be an HTMLElement') - } - - let Timer: number = -1 - let Seq = 0 - let Destroyed = false - let LastMutationAt = performance.now() - let URLHistory: URL = new URL(location.href) - - const EmitChange = (Mutations: MutationRecord[]) => { - TargetEl.dispatchEvent( - new CustomEvent(ChangeEventName, { - detail: { - Seq, - At: LastMutationAt, - MutationCount: Mutations.length, - Mutations, - }, - }), - ) - } - - const EmitSettled = () => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (Destroyed) { - return - } - - TargetEl.dispatchEvent( - new CustomEvent(EventName, { - detail: { - Seq, - QuietMs, - SettledAt: performance.now(), - ElapsedSinceLastMutation: performance.now() - LastMutationAt, - Target: TargetEl, - }, - }), - ) - }) - }) - } - - const EmitUrlChange = () => { - const NewURL = new URL(location.href) - if (NewURL.href !== URLHistory.href) { - URLHistory = NewURL - TargetEl.dispatchEvent( - new CustomEvent(UrlChangeEventName, { - detail: { - Seq, - At: performance.now(), - URL: NewURL, - }, - }), - ) - } - } - - const EmitBlackBlank = () => { - TargetEl.dispatchEvent( - new CustomEvent(UrlBlackBlankEventName, { - detail: { - Seq, - QuietMs, - SettledAt: performance.now(), - ElapsedSinceLastMutation: performance.now() - LastMutationAt, - Target: TargetEl, - } - }) - ) - } - - const ArmSettledTimer = () => { - clearTimeout(Timer) - Timer = setTimeout(EmitSettled, QuietMs) - } - - const Observer = new MutationObserver((Mutations: MutationRecord[]) => { - Seq += 1 - LastMutationAt = performance.now() - - EmitChange(Mutations) - - const AllNodes = Mutations.flatMap(Mutation => [ - ...Mutation.addedNodes, ...Mutation.removedNodes, - ...Mutation.nextSibling ? [Mutation.nextSibling] : [], - ...Mutation.previousSibling ? [Mutation.previousSibling] : [], - ...(Mutation.target ? [Mutation.target] : []) - ]) - - if (AllNodes.length >= 15) { - ArmSettledTimer() - setTimeout(ArmSettledTimer, QuietMs * 3) - } - if (AllNodes.some(MNode => MNode instanceof HTMLElement && parseFloat(getComputedStyle(MNode).getPropertyValue('margin-bottom')) > 10 && MNode.innerText.trim().length === 0)) { - EmitBlackBlank() - } - EmitUrlChange() - }) - - Observer.observe(TargetEl, { - subtree: true, - childList: true, - attributes: true, - characterData: true, - }) - - ArmSettledTimer() - - return { - Disconnect() { - Destroyed = true - clearTimeout(Timer) - Observer.disconnect() - - TargetEl.dispatchEvent( - new CustomEvent('vue:observer-disconnected', { - detail: { Target: TargetEl }, - }), - ) - }, - } -} \ No newline at end of file diff --git a/userscript/tsconfig.json b/userscript/tsconfig.json index 1c4e380..c83ad63 100644 --- a/userscript/tsconfig.json +++ b/userscript/tsconfig.json @@ -5,7 +5,7 @@ "VM.d.ts" ], "compilerOptions": { - "rootDir": "./source", + "rootDir": "./", "outDir": "../dist/", "declaration": true, "skipLibCheck": true From 1af215332236dc8b41f7a7f148588185c2a53a1b Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Wed, 19 Aug 2026 09:36:35 +0000 Subject: [PATCH 02/16] feat: add color manipulation functions and tests for hex color parsing, distance, luminance, contrast, and readability --- testunit/tests/coloring.test.ts | 155 +++++++++++++++++++ userscript/source/coloring.ts | 253 ++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 testunit/tests/coloring.test.ts create mode 100644 userscript/source/coloring.ts diff --git a/testunit/tests/coloring.test.ts b/testunit/tests/coloring.test.ts new file mode 100644 index 0000000..9b9a2fd --- /dev/null +++ b/testunit/tests/coloring.test.ts @@ -0,0 +1,155 @@ +import test from 'ava' +import fc from 'fast-check' +import { ParseHexColor, HexDistance, HexRelativeLuminance, HexContrastRatio, IsReadableTextColor, TextReadabilityScore, IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring.js' + +test('ParseHexColor accepts #RGB, #RRGGBB, and no-# forms', T => { + T.deepEqual(ParseHexColor('#fff'), [255, 255, 255]) + T.deepEqual(ParseHexColor('fff'), [255, 255, 255]) + T.deepEqual(ParseHexColor('#ffffff'), [255, 255, 255]) + T.deepEqual(ParseHexColor('ffffff'), [255, 255, 255]) + T.deepEqual(ParseHexColor('#1a2B3c'), [0x1a, 0x2b, 0x3c]) +}) + +test('ParseHexColor rejects malformed input', T => { + T.throws(() => ParseHexColor('#zzzzzz')) + T.throws(() => ParseHexColor('#12345')) + T.throws(() => ParseHexColor('')) +}) + +test('HexDistance is zero for identical colors and symmetric', T => { + T.is(HexDistance('#123456', '#123456'), 0) + T.is(HexDistance('#123456', '#abcdef'), HexDistance('#abcdef', '#123456')) +}) + +test('HexDistance matches the Euclidean RGB-cube distance for black/white', T => { + T.true(Math.abs(HexDistance('#000000', '#ffffff') - Math.sqrt(255 ** 2 * 3)) < 1e-9) +}) + +test('HexRelativeLuminance matches WCAG black and white endpoints', T => { + T.is(HexRelativeLuminance('#000'), 0) + T.is(HexRelativeLuminance('#fff'), 1) +}) + +test('HexRelativeLuminance follows human-perception channel weights', T => { + T.true(HexRelativeLuminance('#00ff00') > HexRelativeLuminance('#ff0000')) + T.true(HexRelativeLuminance('#ff0000') > HexRelativeLuminance('#0000ff')) +}) + +test('HexContrastRatio matches WCAG contrast ratio endpoints', T => { + T.is(HexContrastRatio('#000', '#fff'), 21) + T.is(HexContrastRatio('#123456', '#123456'), 1) + T.is(HexContrastRatio('#fff', '#000'), HexContrastRatio('#000', '#fff')) +}) + +test('IsReadableTextColor applies WCAG AA and AAA text thresholds', T => { + T.true(IsReadableTextColor('#767676', '#ffffff')) + T.false(IsReadableTextColor('#777777', '#ffffff')) + T.true(IsReadableTextColor('#777777', '#ffffff', { LargeText: true })) + T.false(IsReadableTextColor('#767676', '#ffffff', { Enhanced: true })) + T.true(IsReadableTextColor('#767676', '#ffffff', { LargeText: true, Enhanced: true })) +}) + +test('TextReadabilityScore normalizes contrast ratio for ranking text colors', T => { + T.is(TextReadabilityScore('#000', '#000'), 0) + T.is(TextReadabilityScore('#000', '#fff'), 1) + T.true(TextReadabilityScore('#444444', '#ffffff') > TextReadabilityScore('#777777', '#ffffff')) +}) + +test('text readability helpers reject malformed HEX input', T => { + T.throws(() => HexRelativeLuminance('#zzzzzz')) + T.throws(() => HexContrastRatio('#000', '#12345')) + T.throws(() => IsReadableTextColor('', '#fff')) + T.throws(() => TextReadabilityScore('#000', '')) +}) + +test('IsInsideRegion treats a single-point region as an exact match', T => { + const Region = ['#808080'] + T.true(IsInsideRegion('#808080', Region)) + T.false(IsInsideRegion('#808081', Region)) +}) + +test('IsInsideRegion handles a collinear (line) region', T => { + const Region = ['#000000', '#ffffff'] + T.true(IsInsideRegion('#000000', Region)) + T.true(IsInsideRegion('#ffffff', Region)) + T.true(IsInsideRegion('#404040', Region)) + T.false(IsInsideRegion('#ff0000', Region)) +}) + +test('IsInsideRegion handles a coplanar (polygon) region', T => { + // Square at B=0: R,G both within [50, 200] + const Square = ['#323200', '#c83200', '#c8c800', '#32c800'] + + T.true(IsInsideRegion('#646400', Square)) + T.true(IsInsideRegion('#323200', Square)) + T.false(IsInsideRegion('#0a0a00', Square)) + T.false(IsInsideRegion('#64640a', Square)) +}) + +test('IsInsideRegion handles a volumetric (cube) region', T => { + const Cube = [ + '#323232', '#c83232', '#32c832', '#3232c8', + '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', + ] + + T.true(IsInsideRegion('#7d7d7d', Cube)) + T.true(IsInsideRegion('#323232', Cube)) + T.false(IsInsideRegion('#fafafa', Cube)) +}) + +test('IsInsideRegion always accepts convex combinations of RegionPoints', T => { + const Cube = [ + '#323232', '#c83232', '#32c832', '#3232c8', + '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', + ] + const Points = Cube.map(ParseHexColor) + + fc.assert(fc.property(fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { minLength: Points.length, maxLength: Points.length }), Weights => { + const Total = Weights.reduce((Sum, Weight) => Sum + Weight, 0) + if (Total < 1e-6) return true + + const Normalized = Weights.map(Weight => Weight / Total) + const Combined = Points.reduce((Sum, Point, Index) => [ + Sum[0] + Point[0] * Normalized[Index], + Sum[1] + Point[1] * Normalized[Index], + Sum[2] + Point[2] * Normalized[Index], + ], [0, 0, 0]) + const Hex = `#${Combined.map(Component => Math.round(Component).toString(16).padStart(2, '0')).join('')}` + + return IsInsideRegion(Hex, Cube) + })) + + T.pass() +}) + +test('RegionCentroidRatio is 1 at a single-point region and -1 elsewhere', T => { + const Region = ['#808080'] + T.is(RegionCentroidRatio('#808080', Region), 1) + T.is(RegionCentroidRatio('#808081', Region), -1) +}) + +test('RegionCentroidRatio is 1 at the centroid, 0 on the boundary, and -1 outside a cube region', T => { + const Cube = [ + '#323232', '#c83232', '#32c832', '#3232c8', + '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', + ] + + T.is(RegionCentroidRatio('#7d7d7d', Cube), 1) + T.true(Math.abs(RegionCentroidRatio('#c8c8c8', Cube)) < 1e-6) + T.is(RegionCentroidRatio('#fafafa', Cube), -1) +}) + +test('RegionCentroidRatio decreases monotonically from centroid towards the boundary', T => { + const Cube = [ + '#323232', '#c83232', '#32c832', '#3232c8', + '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', + ] + + const Near = RegionCentroidRatio('#a2a2a2', Cube) + const Middle = RegionCentroidRatio('#b5b5b5', Cube) + const Far = RegionCentroidRatio('#c3c3c3', Cube) + + T.true(Near > Middle) + T.true(Middle > Far) + T.true(Far > 0) +}) diff --git a/userscript/source/coloring.ts b/userscript/source/coloring.ts new file mode 100644 index 0000000..ef24688 --- /dev/null +++ b/userscript/source/coloring.ts @@ -0,0 +1,253 @@ +export type RGB = readonly [number, number, number] + +type Facet = { + Normal: RGB + Offset: number +} + +type AffineBasis = { + Origin: RGB + Basis: RGB[] +} + +const Epsilon = 1e-6 + +export type TextReadabilityOptions = { + LargeText?: boolean + Enhanced?: boolean +} + +/** Parses `#RGB`/`#RRGGBB` (with or without leading `#`) into 0-255 RGB components. */ +export function ParseHexColor(Hex: string): RGB { + const Normalized = Hex.startsWith('#') ? Hex.slice(1) : Hex + const Expanded = Normalized.length === 3 + ? Normalized.split('').map(Char => Char + Char).join('') + : Normalized + + if (!/^[0-9a-fA-F]{6}$/.test(Expanded)) throw new RangeError(`Invalid hex color: ${Hex}`) + + return [ + Number.parseInt(Expanded.slice(0, 2), 16), + Number.parseInt(Expanded.slice(2, 4), 16), + Number.parseInt(Expanded.slice(4, 6), 16), + ] +} + +function Subtract(A: RGB, B: RGB): RGB { + return [A[0] - B[0], A[1] - B[1], A[2] - B[2]] +} + +function Add(A: RGB, B: RGB): RGB { + return [A[0] + B[0], A[1] + B[1], A[2] + B[2]] +} + +function Scale(A: RGB, Factor: number): RGB { + return [A[0] * Factor, A[1] * Factor, A[2] * Factor] +} + +function Dot(A: RGB, B: RGB): number { + return A[0] * B[0] + A[1] * B[1] + A[2] * B[2] +} + +function Length(A: RGB): number { + return Math.hypot(A[0], A[1], A[2]) +} + +function Normalize(A: RGB): RGB { + const Magnitude = Length(A) + return Magnitude < Epsilon ? [0, 0, 0] : Scale(A, 1 / Magnitude) +} + +function Centroid(Points: RGB[]): RGB { + const Sum = Points.reduce((Total, Point) => Add(Total, Point), [0, 0, 0]) + return Scale(Sum, 1 / Points.length) +} + +/** Euclidean RGB-cube distance between two HEX colors (0 = identical, ~441.67 = black/white). */ +export function HexDistance(HexA: string, HexB: string): number { + return Length(Subtract(ParseHexColor(HexA), ParseHexColor(HexB))) +} + +function LinearizeSrgbChannel(Channel: number): number { + const Normalized = Channel / 255 + return Normalized <= 0.04045 ? Normalized / 12.92 : ((Normalized + 0.055) / 1.055) ** 2.4 +} + +/** WCAG relative luminance for a HEX color (0 = black, 1 = white). */ +export function HexRelativeLuminance(Hex: string): number { + const [Red, Green, Blue] = ParseHexColor(Hex) + return 0.2126 * LinearizeSrgbChannel(Red) + + 0.7152 * LinearizeSrgbChannel(Green) + + 0.0722 * LinearizeSrgbChannel(Blue) +} + +/** WCAG contrast ratio between text and background colors (1 = identical, 21 = black/white). */ +export function HexContrastRatio(TextHex: string, BackgroundHex: string): number { + const TextLuminance = HexRelativeLuminance(TextHex) + const BackgroundLuminance = HexRelativeLuminance(BackgroundHex) + const Lighter = Math.max(TextLuminance, BackgroundLuminance) + const Darker = Math.min(TextLuminance, BackgroundLuminance) + return (Lighter + 0.05) / (Darker + 0.05) +} + +/** Whether a text/background color pair satisfies WCAG contrast guidance. */ +export function IsReadableTextColor(TextHex: string, BackgroundHex: string, Options: TextReadabilityOptions = {}): boolean { + const RequiredRatio = Options.Enhanced + ? Options.LargeText ? 4.5 : 7 + : Options.LargeText ? 3 : 4.5 + return HexContrastRatio(TextHex, BackgroundHex) >= RequiredRatio +} + +/** Normalized text readability score based on WCAG contrast ratio (0 = identical, 1 = black/white). */ +export function TextReadabilityScore(TextHex: string, BackgroundHex: string): number { + return (HexContrastRatio(TextHex, BackgroundHex) - 1) / 20 +} + +// Gram-Schmidt against Points[0]; Basis.length is the affine rank (0-3) of the point set. +function DetectAffineBasis(Points: RGB[]): AffineBasis { + const Origin = Points[0] + const Basis: RGB[] = [] + + for (const Point of Points.slice(1)) { + if (Basis.length === 3) break + let Residual = Subtract(Point, Origin) + for (const BasisVector of Basis) Residual = Subtract(Residual, Scale(BasisVector, Dot(Residual, BasisVector))) + if (Length(Residual) > Epsilon) Basis.push(Normalize(Residual)) + } + + return { Origin, Basis } +} + +// Distance from Point to the line/plane/volume spanned by AffineBasisResult (0 when Point lies within it). +function ResidualDistance(Point: RGB, AffineBasisResult: AffineBasis): number { + let Residual = Subtract(Point, AffineBasisResult.Origin) + for (const BasisVector of AffineBasisResult.Basis) Residual = Subtract(Residual, Scale(BasisVector, Dot(Residual, BasisVector))) + return Length(Residual) +} + +function Cross2D(Origin: readonly [number, number], A: readonly [number, number], B: readonly [number, number]): number { + return (A[0] - Origin[0]) * (B[1] - Origin[1]) - (A[1] - Origin[1]) * (B[0] - Origin[0]) +} + +// Andrew's monotone chain; returns hull points in counter-clockwise order. +function ConvexHull2D(Points: (readonly [number, number])[]): (readonly [number, number])[] { + const Sorted = Points.toSorted((A, B) => A[0] - B[0] || A[1] - B[1]) + if (Sorted.length < 3) return Sorted + + const BuildHalf = (Input: (readonly [number, number])[]): (readonly [number, number])[] => { + const Half: (readonly [number, number])[] = [] + for (const Point of Input) { + while (Half.length >= 2 && Cross2D(Half[Half.length - 2], Half[Half.length - 1], Point) <= 0) Half.pop() + Half.push(Point) + } + return Half + } + + const Lower = BuildHalf(Sorted) + const Upper = BuildHalf(Sorted.toReversed()) + return [...Lower.slice(0, -1), ...Upper.slice(0, -1)] +} + +function ComputeFacets(Points: RGB[], AffineBasisResult: AffineBasis): Facet[] { + const { Origin, Basis } = AffineBasisResult + + if (Basis.length === 0) return [] + + if (Basis.length === 1) { + const [Direction] = Basis + const Projections = Points.map(Point => Dot(Subtract(Point, Origin), Direction)) + const MinProjection = Math.min(...Projections) + const MaxProjection = Math.max(...Projections) + return [ + { Normal: Direction, Offset: Dot(Direction, Origin) + MaxProjection }, + { Normal: Scale(Direction, -1), Offset: -(Dot(Direction, Origin) + MinProjection) }, + ] + } + + if (Basis.length === 2) { + const [DirectionA, DirectionB] = Basis + const Projected2D = Points.map((Point): [number, number] => { + const Relative = Subtract(Point, Origin) + return [Dot(Relative, DirectionA), Dot(Relative, DirectionB)] + }) + const Hull2D = ConvexHull2D(Projected2D) + + return Hull2D.map((Vertex, Index): Facet => { + const Next = Hull2D[(Index + 1) % Hull2D.length] + const Edge: [number, number] = [Next[0] - Vertex[0], Next[1] - Vertex[1]] + const Normal2D: [number, number] = [Edge[1], -Edge[0]] + const Normal3D = Add(Scale(DirectionA, Normal2D[0]), Scale(DirectionB, Normal2D[1])) + const Offset2D = Normal2D[0] * Vertex[0] + Normal2D[1] * Vertex[1] + return { Normal: Normal3D, Offset: Dot(Normal3D, Origin) + Offset2D } + }) + } + + // Rank 3: brute-force facet enumeration over point triples (fine for the small RegionPoints sets expected here). + const RegionCentroid = Centroid(Points) + const Facets: Facet[] = [] + for (let I = 0; I < Points.length; I++) { + for (let J = I + 1; J < Points.length; J++) { + for (let K = J + 1; K < Points.length; K++) { + const NormalCandidate: RGB = [ + (Points[J][1] - Points[I][1]) * (Points[K][2] - Points[I][2]) - (Points[J][2] - Points[I][2]) * (Points[K][1] - Points[I][1]), + (Points[J][2] - Points[I][2]) * (Points[K][0] - Points[I][0]) - (Points[J][0] - Points[I][0]) * (Points[K][2] - Points[I][2]), + (Points[J][0] - Points[I][0]) * (Points[K][1] - Points[I][1]) - (Points[J][1] - Points[I][1]) * (Points[K][0] - Points[I][0]), + ] + if (Length(NormalCandidate) < Epsilon) continue + + const Offset = Dot(NormalCandidate, Points[I]) + const CentroidSide = Dot(NormalCandidate, RegionCentroid) - Offset + const OutwardNormal = CentroidSide > 0 ? Scale(NormalCandidate, -1) : NormalCandidate + const OutwardOffset = CentroidSide > 0 ? -Offset : Offset + + const IsFacet = Points.every(Point => Dot(OutwardNormal, Point) <= OutwardOffset + Epsilon) + if (IsFacet) Facets.push({ Normal: OutwardNormal, Offset: OutwardOffset }) + } + } + } + return Facets +} + +/** Whether ComparePointHex lies within (or on the boundary of) the convex hull of RegionPoints. */ +export function IsInsideRegion(ComparePointHex: string, RegionPoints: string[]): boolean { + if (RegionPoints.length === 0) throw new RangeError('RegionPoints must contain at least one color') + + const Points = RegionPoints.map(ParseHexColor) + const ComparePoint = ParseHexColor(ComparePointHex) + const AffineBasisResult = DetectAffineBasis(Points) + + if (ResidualDistance(ComparePoint, AffineBasisResult) > Epsilon) return false + + const Facets = ComputeFacets(Points, AffineBasisResult) + return Facets.every(Facet => Dot(Facet.Normal, ComparePoint) <= Facet.Offset + Epsilon) +} + +/** 1 at the region's centroid, 0 on its boundary, -1 outside; scales linearly in between along the ray from the centroid. */ +export function RegionCentroidRatio(ComparePointHex: string, RegionPoints: string[]): number { + if (RegionPoints.length === 0) throw new RangeError('RegionPoints must contain at least one color') + + const Points = RegionPoints.map(ParseHexColor) + const ComparePoint = ParseHexColor(ComparePointHex) + const AffineBasisResult = DetectAffineBasis(Points) + + if (ResidualDistance(ComparePoint, AffineBasisResult) > Epsilon) return -1 + + const RegionCentroid = Centroid(Points) + const Direction = Subtract(ComparePoint, RegionCentroid) + if (Length(Direction) < Epsilon) return 1 + + const Facets = ComputeFacets(Points, AffineBasisResult) + if (Facets.length === 0) return -1 + + let ExitParameter = Infinity + for (const Facet of Facets) { + const NormalDotDirection = Dot(Facet.Normal, Direction) + if (NormalDotDirection <= Epsilon) continue + const Parameter = (Facet.Offset - Dot(Facet.Normal, RegionCentroid)) / NormalDotDirection + if (Parameter < ExitParameter) ExitParameter = Parameter + } + + if (!isFinite(ExitParameter) || ExitParameter < 1 - Epsilon) return -1 + if (ExitParameter <= 1 + Epsilon) return 0 + return 1 - 1 / ExitParameter +} From c8b0dbd578844dbc297a54cd864f79ee7431ff41 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Wed, 19 Aug 2026 09:37:08 +0000 Subject: [PATCH 03/16] refactor: remove unused import from startrick.js --- userscript/source/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userscript/source/index.ts b/userscript/source/index.ts index 481c208..8618246 100644 --- a/userscript/source/index.ts +++ b/userscript/source/index.ts @@ -12,7 +12,7 @@ type unsafeWindow = typeof window // eslint-disable-next-line @typescript-eslint/naming-convention declare const unsafeWindow: unsafeWindow -import { DeleteValueAtPath, MatchValueSchema, ParsePath, SetValueAtPath, type ValueSchema } from './startrick.js' +import { MatchValueSchema, SetValueAtPath, type ValueSchema } from './startrick.js' const Win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window const UserscriptName = 'NamuLink' From cf70b1f965e9bda384d663daf8002139f279181c Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Wed, 19 Aug 2026 10:38:28 +0000 Subject: [PATCH 04/16] feat: implement coloring worker functionality and associated types, tests, and runtime support --- builder/source/build.ts | 13 ++++ testunit/package.json | 1 + testunit/tests/coloring-worker.test.ts | 57 ++++++++++++++ userscript/source/coloring-client.ts | 69 +++++++++++++++++ userscript/source/coloring-types.ts | 29 +++++++ userscript/source/coloring-worker.ts | 33 ++++++++ userscript/source/worker-runtime.ts | 102 +++++++++++++++++++++++++ 7 files changed, 304 insertions(+) create mode 100644 testunit/tests/coloring-worker.test.ts create mode 100644 userscript/source/coloring-client.ts create mode 100644 userscript/source/coloring-types.ts create mode 100644 userscript/source/coloring-worker.ts create mode 100644 userscript/source/worker-runtime.ts diff --git a/builder/source/build.ts b/builder/source/build.ts index 4b1e6ec..22660ee 100644 --- a/builder/source/build.ts +++ b/builder/source/build.ts @@ -110,6 +110,16 @@ export async function Build(OptionsParam?: BuildOptions): Promise { } }) + // Bundled separately (not inlined via the virtual entry) so it can be embedded as a string and run inside a Worker/worker_threads. + const ColoringWorkerCode = await ESBuild.build({ + entryPoints: [Path.resolve(ProjectRoot, 'userscript', 'source', 'coloring-worker.ts')], + bundle: true, + minify: Options.Minify, + write: false, + external: ['node:worker_threads'], + target: ['es2024', 'chrome119', 'firefox142', 'safari26'] + }) + const VirtualIndexEntry = await CreateVirtualIndexEntry(ProjectRoot) await ESBuild.build({ @@ -122,6 +132,9 @@ export async function Build(OptionsParam?: BuildOptions): Promise { js: Banner }, target: ['es2024', 'chrome119', 'firefox142', 'safari26'], + define: { + __COLORING_WORKER_CODE__: JSON.stringify(ColoringWorkerCode.outputFiles[0].text) + }, plugins: [ CreateVirtualIndexEntryPlugin(VirtualIndexEntry.EntryPath, VirtualIndexEntry.FileSystem) ] diff --git a/testunit/package.json b/testunit/package.json index 78e1128..e14a0e4 100644 --- a/testunit/package.json +++ b/testunit/package.json @@ -29,6 +29,7 @@ "@typescript-eslint/parser": "^8.59.4", "@violentmonkey/types": "^0.3.3", "ava": "^8.0.1", + "esbuild": "^0.28.0", "eslint": "^10.4.0", "fast-check": "^4.9.0", "tsx": "^4.22.4", diff --git a/testunit/tests/coloring-worker.test.ts b/testunit/tests/coloring-worker.test.ts new file mode 100644 index 0000000..8985f46 --- /dev/null +++ b/testunit/tests/coloring-worker.test.ts @@ -0,0 +1,57 @@ +import test from 'ava' +import * as Path from 'node:path' +import * as ESBuild from 'esbuild' +import { IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring.js' +import { CreateColoringWorkerPool } from '@userscript/coloring-client.js' +import type { ColoringBatchItem, ColoringBatchResultValue } from '@userscript/coloring-types.js' + +const Regions: Record = { + Grayscale: ['#000000', '#ffffff'], + Warm: ['#ff0000', '#ffff00', '#996633'] +} + +function BuildDataset(): ColoringBatchItem[] { + const CandidateColors = ['#000000', '#111111', '#7f7f7f', '#ffffff', '#ff8800', '#336699', '#abcdef', '#123456'] + const Items: ColoringBatchItem[] = [] + + for (const ColorHex of CandidateColors) { + for (const RegionPoints of Object.values(Regions)) { + Items.push({ Op: 'IsInsideRegion', ComparePointHex: ColorHex, RegionPoints }) + Items.push({ Op: 'RegionCentroidRatio', ComparePointHex: ColorHex, RegionPoints }) + } + } + + return Items +} + +function RunDirectly(Items: ColoringBatchItem[]): ColoringBatchResultValue[] { + return Items.map(Item => Item.Op === 'IsInsideRegion' + ? IsInsideRegion(Item.ComparePointHex, Item.RegionPoints) + : RegionCentroidRatio(Item.ComparePointHex, Item.RegionPoints)) +} + +test('coloring batch logic is deterministic when called directly (no worker)', T => { + const Items = BuildDataset() + T.deepEqual(RunDirectly(Items), RunDirectly(Items)) +}) + +test('CreateColoringWorkerPool spreads a batch across worker_threads and matches direct-call results', async T => { + const EntryPath = Path.resolve(import.meta.dirname, '../../userscript/source/coloring-worker.ts') + const BuildResult = await ESBuild.build({ + entryPoints: [EntryPath], + bundle: true, + write: false, + external: ['node:worker_threads'], + target: ['es2024'] + }) + const Code = BuildResult.outputFiles[0].text + + const Pool = await CreateColoringWorkerPool(Code, 3) + try { + const Items = BuildDataset() + const Results = await Pool.RunBatch(Items) + T.deepEqual(Results, RunDirectly(Items)) + } finally { + Pool.Terminate() + } +}) diff --git a/userscript/source/coloring-client.ts b/userscript/source/coloring-client.ts new file mode 100644 index 0000000..076f96e --- /dev/null +++ b/userscript/source/coloring-client.ts @@ -0,0 +1,69 @@ +import { CreateIsomorphicWorker, type WorkerLike } from './worker-runtime.js' +import type { ColoringBatchItem, ColoringBatchRequest, ColoringBatchResponse, ColoringBatchResultValue } from './coloring-types.js' + +export type ColoringWorkerPool = { + RunBatch(Items: ColoringBatchItem[]): Promise + Terminate(): void +} + +type PendingResolver = { + Resolve(Value: ColoringBatchResultValue[]): void + Reject(Reason: unknown): void +} + +function SplitIntoChunks(Items: T[], ChunkCount: number): T[][] { + const ChunkSize = Math.ceil(Items.length / ChunkCount) + const Chunks: T[][] = [] + for (let Index = 0; Index < Items.length; Index += ChunkSize) Chunks.push(Items.slice(Index, Index + ChunkSize)) + return Chunks +} + +// Registers a single message listener per worker so concurrent RunBatch calls don't leak listeners. +function AttachResponseHandling(WorkerInstance: WorkerLike): Map { + const Pending = new Map() + + WorkerInstance.OnMessage((Data) => { + const Message = Data as ColoringBatchResponse + if (!Message || !Message.RequestId) return + + const PendingRequest = Pending.get(Message.RequestId) + if (!PendingRequest) return + Pending.delete(Message.RequestId) + + if (Message.Kind === 'batch-result') PendingRequest.Resolve(Message.Results) + else PendingRequest.Reject(new Error(Message.Error)) + }) + + return Pending +} + +function RunOnWorker(WorkerInstance: WorkerLike, Pending: Map, Items: ColoringBatchItem[]): Promise { + if (Items.length === 0) return Promise.resolve([]) + + const RequestId = `coloring-${Date.now()}-${Math.random().toString(36).slice(2)}` + const Request: ColoringBatchRequest = { Kind: 'batch', RequestId, Items } + + return new Promise((Resolve, Reject) => { + Pending.set(RequestId, { Resolve, Reject }) + WorkerInstance.PostMessage(Request) + }) +} + +/** Distributes color/region checks across a pool of Workers (browser) or worker_threads (Node), preserving item order. */ +export async function CreateColoringWorkerPool(Code: string, PoolSize: number): Promise { + if (PoolSize < 1) throw new RangeError('PoolSize must be at least 1') + + const Workers = await Promise.all(Array.from({ length: PoolSize }, () => CreateIsomorphicWorker(Code))) + const PendingByWorker = Workers.map(AttachResponseHandling) + + return { + async RunBatch(Items: ColoringBatchItem[]): Promise { + const Chunks = SplitIntoChunks(Items, Workers.length) + const ChunkResults = await Promise.all(Chunks.map((Chunk, Index) => RunOnWorker(Workers[Index], PendingByWorker[Index], Chunk))) + return ChunkResults.flat() + }, + Terminate() { + Workers.forEach(WorkerInstance => WorkerInstance.Terminate()) + } + } +} diff --git a/userscript/source/coloring-types.ts b/userscript/source/coloring-types.ts new file mode 100644 index 0000000..c08fcfa --- /dev/null +++ b/userscript/source/coloring-types.ts @@ -0,0 +1,29 @@ +export type ColoringOperation = 'IsInsideRegion' | 'RegionCentroidRatio' + +export type ColoringBatchItem = { + Op: ColoringOperation + ComparePointHex: string + RegionPoints: string[] +} + +export type ColoringBatchResultValue = boolean | number + +export type ColoringBatchRequest = { + Kind: 'batch' + RequestId: string + Items: ColoringBatchItem[] +} + +export type ColoringBatchSuccessResponse = { + Kind: 'batch-result' + RequestId: string + Results: ColoringBatchResultValue[] +} + +export type ColoringBatchErrorResponse = { + Kind: 'batch-error' + RequestId: string + Error: string +} + +export type ColoringBatchResponse = ColoringBatchSuccessResponse | ColoringBatchErrorResponse diff --git a/userscript/source/coloring-worker.ts b/userscript/source/coloring-worker.ts new file mode 100644 index 0000000..8a58a9b --- /dev/null +++ b/userscript/source/coloring-worker.ts @@ -0,0 +1,33 @@ +import { IsInsideRegion, RegionCentroidRatio } from './coloring.js' +import { GetWorkerPort } from './worker-runtime.js' +import type { ColoringBatchItem, ColoringBatchRequest, ColoringBatchResponse, ColoringBatchResultValue } from './coloring-types.js' + +function RunItem(Item: ColoringBatchItem): ColoringBatchResultValue { + return Item.Op === 'IsInsideRegion' + ? IsInsideRegion(Item.ComparePointHex, Item.RegionPoints) + : RegionCentroidRatio(Item.ComparePointHex, Item.RegionPoints) +} + +void (async () => { + const Port = await GetWorkerPort() + + Port.OnMessage((Data) => { + const Message = Data as ColoringBatchRequest + if (!Message || Message.Kind !== 'batch') return + + try { + const Results = Message.Items.map(RunItem) + const Response: ColoringBatchResponse = { Kind: 'batch-result', RequestId: Message.RequestId, Results } + Port.PostMessage(Response) + } catch (ErrorValue) { + const Response: ColoringBatchResponse = { + Kind: 'batch-error', + RequestId: Message.RequestId, + Error: ErrorValue instanceof Error ? ErrorValue.message : String(ErrorValue) + } + Port.PostMessage(Response) + } + }) +})() + +export {} diff --git a/userscript/source/worker-runtime.ts b/userscript/source/worker-runtime.ts new file mode 100644 index 0000000..9e858ae --- /dev/null +++ b/userscript/source/worker-runtime.ts @@ -0,0 +1,102 @@ +// Minimal shape for the subset of worker_threads used here, so this browser-focused package doesn't need @types/node. +type NodeMessagePort = { + on(EventName: 'message', Listener: (Value: unknown) => void): void + postMessage(Value: unknown): void +} +// eslint-disable-next-line @typescript-eslint/naming-convention -- must match Node's worker_threads Worker options shape +type NodeWorkerConstructor = new (FileNameOrCode: string, Options?: { eval?: boolean }) => { + on(EventName: 'message' | 'error', Listener: (Value: unknown) => void): void + postMessage(Value: unknown): void + terminate(): Promise +} +type NodeWorkerThreadsModule = { + // eslint-disable-next-line @typescript-eslint/naming-convention -- must match Node's worker_threads export name + parentPort: NodeMessagePort | null + Worker: NodeWorkerConstructor +} + +// A non-literal specifier keeps TS from statically resolving 'node:worker_threads', which this package has no types for. +const WorkerThreadsModuleName = 'node:worker_threads' + +async function ImportWorkerThreads(): Promise { + return (await import(WorkerThreadsModuleName)) as NodeWorkerThreadsModule +} + +export type PortLike = { + OnMessage(Callback: (Data: unknown) => void): void + PostMessage(Data: unknown): void +} + +export type WorkerLike = PortLike & { + OnError(Callback: (ErrorValue: unknown) => void): void + Terminate(): void +} + +/** Resolves the current context's message port: browser Worker scope (self) vs Node worker_threads (parentPort). */ +export async function GetWorkerPort(): Promise { + if (typeof self !== 'undefined') { + return { + OnMessage(Callback) { + self.addEventListener('message', (EventValue: MessageEvent) => Callback(EventValue.data)) + }, + PostMessage(Data) { + self.postMessage(Data) + } + } + } + + // eslint-disable-next-line @typescript-eslint/naming-convention -- must match Node's worker_threads export name + const { parentPort } = await ImportWorkerThreads() + if (!parentPort) throw new Error('parentPort is unavailable outside a worker_threads worker') + + return { + OnMessage(Callback) { + parentPort.on('message', Callback) + }, + PostMessage(Data) { + parentPort.postMessage(Data) + } + } +} + +/** Instantiates a Worker from inline source: Blob URL in browsers, worker_threads `eval` in Node. */ +export async function CreateIsomorphicWorker(Code: string): Promise { + if (typeof Worker !== 'undefined' && typeof Blob !== 'undefined') { + const BlobUrl = URL.createObjectURL(new Blob([Code], { type: 'application/javascript' })) + const WorkerInstance = new Worker(BlobUrl) + + return { + OnMessage(Callback) { + WorkerInstance.addEventListener('message', (EventValue: MessageEvent) => Callback(EventValue.data)) + }, + OnError(Callback) { + WorkerInstance.addEventListener('error', Callback) + }, + PostMessage(Data) { + WorkerInstance.postMessage(Data) + }, + Terminate() { + WorkerInstance.terminate() + URL.revokeObjectURL(BlobUrl) + } + } + } + + const { Worker: NodeWorker } = await ImportWorkerThreads() + const WorkerInstance = new NodeWorker(Code, { eval: true }) + + return { + OnMessage(Callback) { + WorkerInstance.on('message', Callback) + }, + OnError(Callback) { + WorkerInstance.on('error', Callback) + }, + PostMessage(Data) { + WorkerInstance.postMessage(Data) + }, + Terminate() { + void WorkerInstance.terminate() + } + } +} From 28d09c65d22c102027f5bc04c4c72ac3394fc00e Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Wed, 19 Aug 2026 10:53:24 +0000 Subject: [PATCH 05/16] feat: enhance `RegionCentroidRatio` with geometric centroid calculations and add related tests --- testunit/tests/coloring.test.ts | 27 +++++++ userscript/source/coloring.ts | 124 +++++++++++++++++++++++++------- 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/testunit/tests/coloring.test.ts b/testunit/tests/coloring.test.ts index 9b9a2fd..f7f06c0 100644 --- a/testunit/tests/coloring.test.ts +++ b/testunit/tests/coloring.test.ts @@ -128,6 +128,33 @@ test('RegionCentroidRatio is 1 at a single-point region and -1 elsewhere', T => T.is(RegionCentroidRatio('#808081', Region), -1) }) +test('RegionCentroidRatio uses the midpoint of a collinear hull instead of the point average', T => { + const Region = ['#000000', '#0a0a0a', '#c8c8c8'] + + T.is(RegionCentroidRatio('#646464', Region), 1) + T.not(RegionCentroidRatio('#464646', Region), 1) +}) + +test('RegionCentroidRatio uses the area centroid of a polygon instead of the point average', T => { + const Region = ['#000000', '#c80000', '#c8c800', '#00c800', '#006400'] + + T.is(RegionCentroidRatio('#646400', Region), 1) + T.not(RegionCentroidRatio('#506400', Region), 1) +}) + +test('RegionCentroidRatio uses the divergence-theorem volume centroid of a pyramid', T => { + const Pyramid = ['#000000', '#c80000', '#c8c800', '#00c800', '#6464c8'] + + T.is(RegionCentroidRatio('#646432', Pyramid), 1) + T.not(RegionCentroidRatio('#646428', Pyramid), 1) +}) + +test('RegionCentroidRatio ignores repeated points when finding a volume centroid', T => { + const Pyramid = ['#000000', '#c80000', '#c8c800', '#00c800', '#6464c8', '#6464c8'] + + T.is(RegionCentroidRatio('#646432', Pyramid), 1) +}) + test('RegionCentroidRatio is 1 at the centroid, 0 on the boundary, and -1 outside a cube region', T => { const Cube = [ '#323232', '#c83232', '#32c832', '#3232c8', diff --git a/userscript/source/coloring.ts b/userscript/source/coloring.ts index ef24688..3299486 100644 --- a/userscript/source/coloring.ts +++ b/userscript/source/coloring.ts @@ -5,6 +5,10 @@ type Facet = { Offset: number } +type Face = Facet & { + Vertices: RGB[] +} + type AffineBasis = { Origin: RGB Basis: RGB[] @@ -49,6 +53,14 @@ function Dot(A: RGB, B: RGB): number { return A[0] * B[0] + A[1] * B[1] + A[2] * B[2] } +function Cross(A: RGB, B: RGB): RGB { + return [ + A[1] * B[2] - A[2] * B[1], + A[2] * B[0] - A[0] * B[2], + A[0] * B[1] - A[1] * B[0], + ] +} + function Length(A: RGB): number { return Math.hypot(A[0], A[1], A[2]) } @@ -58,7 +70,7 @@ function Normalize(A: RGB): RGB { return Magnitude < Epsilon ? [0, 0, 0] : Scale(A, 1 / Magnitude) } -function Centroid(Points: RGB[]): RGB { +function PointAverage(Points: RGB[]): RGB { const Sum = Points.reduce((Total, Point) => Add(Total, Point), [0, 0, 0]) return Scale(Sum, 1 / Points.length) } @@ -148,6 +160,86 @@ function ConvexHull2D(Points: (readonly [number, number])[]): (readonly [number, return [...Lower.slice(0, -1), ...Upper.slice(0, -1)] } +function ComputeFaces3D(Points: RGB[]): Face[] { + const UniquePoints = Points.filter((Point, Index) => !Points.slice(0, Index).some(Previous => Previous[0] === Point[0] && Previous[1] === Point[1] && Previous[2] === Point[2])) + const InteriorPoint = PointAverage(UniquePoints) + const Faces: Face[] = [] + + for (let I = 0; I < UniquePoints.length; I++) { + for (let J = I + 1; J < UniquePoints.length; J++) { + for (let K = J + 1; K < UniquePoints.length; K++) { + const NormalCandidate = Cross(Subtract(UniquePoints[J], UniquePoints[I]), Subtract(UniquePoints[K], UniquePoints[I])) + if (Length(NormalCandidate) < Epsilon) continue + + const Offset = Dot(NormalCandidate, UniquePoints[I]) + const InteriorSide = Dot(NormalCandidate, InteriorPoint) - Offset + const Normal = InteriorSide > 0 ? Scale(NormalCandidate, -1) : NormalCandidate + const OutwardOffset = InteriorSide > 0 ? -Offset : Offset + if (!UniquePoints.every(Point => Dot(Normal, Point) <= OutwardOffset + Epsilon)) continue + + const Vertices = UniquePoints.filter(Point => Math.abs(Dot(Normal, Point) - OutwardOffset) <= Epsilon) + if (Faces.some(FaceValue => FaceValue.Vertices.length === Vertices.length && FaceValue.Vertices.every(Point => Vertices.includes(Point)))) continue + + const FaceCenter = PointAverage(Vertices) + const AxisA = Normalize(Subtract(Vertices[0], FaceCenter)) + const AxisB = Normalize(Cross(Normal, AxisA)) + const OrderedVertices = Vertices.toSorted((A, B) => Math.atan2(Dot(Subtract(A, FaceCenter), AxisB), Dot(Subtract(A, FaceCenter), AxisA)) + - Math.atan2(Dot(Subtract(B, FaceCenter), AxisB), Dot(Subtract(B, FaceCenter), AxisA))) + Faces.push({ Normal, Offset: OutwardOffset, Vertices: OrderedVertices }) + } + } + } + + return Faces +} + +function GeometricCentroid(Points: RGB[], AffineBasisResult: AffineBasis): RGB { + const { Origin, Basis } = AffineBasisResult + if (Basis.length === 0) return Origin + + if (Basis.length === 1) { + const [Direction] = Basis + const Projections = Points.map(Point => Dot(Subtract(Point, Origin), Direction)) + return Add(Origin, Scale(Direction, (Math.min(...Projections) + Math.max(...Projections)) / 2)) + } + + if (Basis.length === 2) { + const [DirectionA, DirectionB] = Basis + const Hull = ConvexHull2D(Points.map(Point => { + const Relative = Subtract(Point, Origin) + return [Dot(Relative, DirectionA), Dot(Relative, DirectionB)] as const + })) + let TwiceArea = 0 + let WeightedX = 0 + let WeightedY = 0 + for (let Index = 0; Index < Hull.length; Index++) { + const Current = Hull[Index] + const Next = Hull[(Index + 1) % Hull.length] + const CrossValue = Current[0] * Next[1] - Next[0] * Current[1] + TwiceArea += CrossValue + WeightedX += (Current[0] + Next[0]) * CrossValue + WeightedY += (Current[1] + Next[1]) * CrossValue + } + if (Math.abs(TwiceArea) < Epsilon) return PointAverage(Points) + return Add(Origin, Add(Scale(DirectionA, WeightedX / (3 * TwiceArea)), Scale(DirectionB, WeightedY / (3 * TwiceArea)))) + } + + let SignedVolume = 0 + let VolumeMoment: RGB = [0, 0, 0] + for (const FaceValue of ComputeFaces3D(Points)) { + const [First, ...Remaining] = FaceValue.Vertices + for (let Index = 0; Index < Remaining.length - 1; Index++) { + const Second = Remaining[Index] + const Third = Remaining[Index + 1] + const TetrahedronVolume = Dot(First, Cross(Second, Third)) / 6 + SignedVolume += TetrahedronVolume + VolumeMoment = Add(VolumeMoment, Scale(Add(Add(First, Second), Third), TetrahedronVolume / 4)) + } + } + + return Math.abs(SignedVolume) < Epsilon ? PointAverage(Points) : Scale(VolumeMoment, 1 / SignedVolume) +} + function ComputeFacets(Points: RGB[], AffineBasisResult: AffineBasis): Facet[] { const { Origin, Basis } = AffineBasisResult @@ -182,30 +274,8 @@ function ComputeFacets(Points: RGB[], AffineBasisResult: AffineBasis): Facet[] { }) } - // Rank 3: brute-force facet enumeration over point triples (fine for the small RegionPoints sets expected here). - const RegionCentroid = Centroid(Points) - const Facets: Facet[] = [] - for (let I = 0; I < Points.length; I++) { - for (let J = I + 1; J < Points.length; J++) { - for (let K = J + 1; K < Points.length; K++) { - const NormalCandidate: RGB = [ - (Points[J][1] - Points[I][1]) * (Points[K][2] - Points[I][2]) - (Points[J][2] - Points[I][2]) * (Points[K][1] - Points[I][1]), - (Points[J][2] - Points[I][2]) * (Points[K][0] - Points[I][0]) - (Points[J][0] - Points[I][0]) * (Points[K][2] - Points[I][2]), - (Points[J][0] - Points[I][0]) * (Points[K][1] - Points[I][1]) - (Points[J][1] - Points[I][1]) * (Points[K][0] - Points[I][0]), - ] - if (Length(NormalCandidate) < Epsilon) continue - - const Offset = Dot(NormalCandidate, Points[I]) - const CentroidSide = Dot(NormalCandidate, RegionCentroid) - Offset - const OutwardNormal = CentroidSide > 0 ? Scale(NormalCandidate, -1) : NormalCandidate - const OutwardOffset = CentroidSide > 0 ? -Offset : Offset - - const IsFacet = Points.every(Point => Dot(OutwardNormal, Point) <= OutwardOffset + Epsilon) - if (IsFacet) Facets.push({ Normal: OutwardNormal, Offset: OutwardOffset }) - } - } - } - return Facets + // Rank 3: enumerate the small expected point sets, grouping coplanar triples into faces. + return ComputeFaces3D(Points) } /** Whether ComparePointHex lies within (or on the boundary of) the convex hull of RegionPoints. */ @@ -222,7 +292,7 @@ export function IsInsideRegion(ComparePointHex: string, RegionPoints: string[]): return Facets.every(Facet => Dot(Facet.Normal, ComparePoint) <= Facet.Offset + Epsilon) } -/** 1 at the region's centroid, 0 on its boundary, -1 outside; scales linearly in between along the ray from the centroid. */ +/** 1 at the convex hull's geometric centroid, 0 on its boundary, -1 outside; scales linearly in between along the ray from the centroid. */ export function RegionCentroidRatio(ComparePointHex: string, RegionPoints: string[]): number { if (RegionPoints.length === 0) throw new RangeError('RegionPoints must contain at least one color') @@ -232,7 +302,7 @@ export function RegionCentroidRatio(ComparePointHex: string, RegionPoints: strin if (ResidualDistance(ComparePoint, AffineBasisResult) > Epsilon) return -1 - const RegionCentroid = Centroid(Points) + const RegionCentroid = GeometricCentroid(Points, AffineBasisResult) const Direction = Subtract(ComparePoint, RegionCentroid) if (Length(Direction) < Epsilon) return 1 From a4ad9f0a4c4c7084ccd4d111ebaf007fabe94819 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Wed, 19 Aug 2026 11:43:44 +0000 Subject: [PATCH 06/16] feat: optimize request ID generation and enhance uniqueness in `ComputeFaces3D` function --- userscript/source/coloring-client.ts | 2 +- userscript/source/coloring.ts | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/userscript/source/coloring-client.ts b/userscript/source/coloring-client.ts index 076f96e..57dc1d5 100644 --- a/userscript/source/coloring-client.ts +++ b/userscript/source/coloring-client.ts @@ -40,7 +40,7 @@ function AttachResponseHandling(WorkerInstance: WorkerLike): Map, Items: ColoringBatchItem[]): Promise { if (Items.length === 0) return Promise.resolve([]) - const RequestId = `coloring-${Date.now()}-${Math.random().toString(36).slice(2)}` + const RequestId = `coloring-${crypto.randomUUID()}` const Request: ColoringBatchRequest = { Kind: 'batch', RequestId, Items } return new Promise((Resolve, Reject) => { diff --git a/userscript/source/coloring.ts b/userscript/source/coloring.ts index 3299486..5c49524 100644 --- a/userscript/source/coloring.ts +++ b/userscript/source/coloring.ts @@ -160,10 +160,21 @@ function ConvexHull2D(Points: (readonly [number, number])[]): (readonly [number, return [...Lower.slice(0, -1), ...Upper.slice(0, -1)] } +function PointKey(Point: RGB): string { + return `${Point[0]},${Point[1]},${Point[2]}` +} + function ComputeFaces3D(Points: RGB[]): Face[] { - const UniquePoints = Points.filter((Point, Index) => !Points.slice(0, Index).some(Previous => Previous[0] === Point[0] && Previous[1] === Point[1] && Previous[2] === Point[2])) + const PointKeys = new Set() + const UniquePoints = Points.filter(Point => { + const Key = PointKey(Point) + if (PointKeys.has(Key)) return false + PointKeys.add(Key) + return true + }) const InteriorPoint = PointAverage(UniquePoints) const Faces: Face[] = [] + const FaceKeys = new Set() for (let I = 0; I < UniquePoints.length; I++) { for (let J = I + 1; J < UniquePoints.length; J++) { @@ -178,7 +189,9 @@ function ComputeFaces3D(Points: RGB[]): Face[] { if (!UniquePoints.every(Point => Dot(Normal, Point) <= OutwardOffset + Epsilon)) continue const Vertices = UniquePoints.filter(Point => Math.abs(Dot(Normal, Point) - OutwardOffset) <= Epsilon) - if (Faces.some(FaceValue => FaceValue.Vertices.length === Vertices.length && FaceValue.Vertices.every(Point => Vertices.includes(Point)))) continue + const FaceKey = Vertices.map(PointKey).toSorted().join('|') + if (FaceKeys.has(FaceKey)) continue + FaceKeys.add(FaceKey) const FaceCenter = PointAverage(Vertices) const AxisA = Normalize(Subtract(Vertices[0], FaceCenter)) From ba58eaea1a4501d265e1d36809eb7f18c23322e4 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Thu, 27 Aug 2026 20:27:05 +0000 Subject: [PATCH 07/16] ci: update bump-packagejson-version action to latest commit --- .github/workflows/npm.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 022d48c..72cd76d 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: pnpm install --no-lockfile - name: Bump package.json version from tag - uses: TypescriptPrime/bump-packagejson-version@72720c4d073ed0c5b9e9393d7334abfe3fabb47c + uses: TypescriptPrime/bump-packagejson-version@8b461d950090eae85d58d8a0085bfc993abdea0d - name: Build run: npm run build:stable - name : Publish to npm @@ -59,7 +59,7 @@ jobs: - name: Install dependencies run: pnpm install --no-lockfile - name: Bump package.json version from tag - uses: TypescriptPrime/bump-packagejson-version@72720c4d073ed0c5b9e9393d7334abfe3fabb47c + uses: TypescriptPrime/bump-packagejson-version@8b461d950090eae85d58d8a0085bfc993abdea0d - name: Build run: npm run build:stable - name : Publish to npm @@ -88,7 +88,7 @@ jobs: - name: Install dependencies run: pnpm install --no-lockfile - name: Bump package.json version from tag - uses: TypescriptPrime/bump-packagejson-version@72720c4d073ed0c5b9e9393d7334abfe3fabb47c + uses: TypescriptPrime/bump-packagejson-version@8b461d950090eae85d58d8a0085bfc993abdea0d - name: Build run: npm run build:dev - name : Publish to npm From 43b3ec48444532ba7bbfa35022509e58cdd4f90c Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Sun, 30 Aug 2026 00:29:51 +0000 Subject: [PATCH 08/16] feat: add section for receiving update notifications via Discord and Telegram in README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 4bb032d..e99c9c5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ NamuLink 유저스크립트는 AdGuard와 애드블록 커뮤니티에 의해 > > 다른 애드블록 지원은 보장되지 않고 요청되어도 거부될 수 있습니다. +## Discord/Telegram로 업데이트 알림 받기 + +[Discord 봇](https://discord.com/oauth2/authorize?client_id=1543001264776814723) 또는 Telegram `@filteringdev_noti_bot`을 통해 업데이트 알림을 받을 수 있습니다. + +## 설치 + ### 빠른 시작 아래 URL를 클릭하여 설치해주세요: From cd390f4cff10e576590d512bad396abeba51cbe9 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Sun, 30 Aug 2026 15:32:32 +0000 Subject: [PATCH 09/16] chore: migrate tests from AVA to Vitest and update package.json accordingly --- testunit/package.json | 23 +--- testunit/tests/coloring-worker.test.ts | 10 +- testunit/tests/coloring.test.ts | 164 ++++++++++++------------- testunit/tests/index.test.ts | 137 ++++++++++----------- testunit/vitest.config.ts | 13 ++ 5 files changed, 170 insertions(+), 177 deletions(-) create mode 100644 testunit/vitest.config.ts diff --git a/testunit/package.json b/testunit/package.json index e14a0e4..e77d69f 100644 --- a/testunit/package.json +++ b/testunit/package.json @@ -4,35 +4,18 @@ "type": "module", "scripts": { "lint": "tsc --noEmit && eslint **/*.ts", - "test": "ava" - }, - "ava": { - "files": [ - "tests/**/*.test.ts" - ], - "nodeArguments": [ - "--import=tsx" - ], - "workerThreads": false, - "typescript": { - "rewritePaths": { - "@userscript/": "./sources/" - }, - "compile": false - } + "test": "vitest run" }, "devDependencies": { - "@ava/typescript": "^7.0.0", "@types/node": "^24.13.1", "@types/web": "^0.0.345", "@typescript-eslint/eslint-plugin": "^8.59.4", "@typescript-eslint/parser": "^8.59.4", "@violentmonkey/types": "^0.3.3", - "ava": "^8.0.1", "esbuild": "^0.28.0", "eslint": "^10.4.0", "fast-check": "^4.9.0", - "tsx": "^4.22.4", - "typescript-eslint": "^8.59.4" + "typescript-eslint": "^8.59.4", + "vitest": "^3.2.4" } } diff --git a/testunit/tests/coloring-worker.test.ts b/testunit/tests/coloring-worker.test.ts index 8985f46..b26479b 100644 --- a/testunit/tests/coloring-worker.test.ts +++ b/testunit/tests/coloring-worker.test.ts @@ -1,4 +1,4 @@ -import test from 'ava' +import { test, expect } from 'vitest' import * as Path from 'node:path' import * as ESBuild from 'esbuild' import { IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring.js' @@ -30,12 +30,12 @@ function RunDirectly(Items: ColoringBatchItem[]): ColoringBatchResultValue[] { : RegionCentroidRatio(Item.ComparePointHex, Item.RegionPoints)) } -test('coloring batch logic is deterministic when called directly (no worker)', T => { +test('coloring batch logic is deterministic when called directly (no worker)', () => { const Items = BuildDataset() - T.deepEqual(RunDirectly(Items), RunDirectly(Items)) + expect(RunDirectly(Items)).toEqual(RunDirectly(Items)) }) -test('CreateColoringWorkerPool spreads a batch across worker_threads and matches direct-call results', async T => { +test('CreateColoringWorkerPool spreads a batch across worker_threads and matches direct-call results', async () => { const EntryPath = Path.resolve(import.meta.dirname, '../../userscript/source/coloring-worker.ts') const BuildResult = await ESBuild.build({ entryPoints: [EntryPath], @@ -50,7 +50,7 @@ test('CreateColoringWorkerPool spreads a batch across worker_threads and matches try { const Items = BuildDataset() const Results = await Pool.RunBatch(Items) - T.deepEqual(Results, RunDirectly(Items)) + expect(Results).toEqual(RunDirectly(Items)) } finally { Pool.Terminate() } diff --git a/testunit/tests/coloring.test.ts b/testunit/tests/coloring.test.ts index f7f06c0..513858a 100644 --- a/testunit/tests/coloring.test.ts +++ b/testunit/tests/coloring.test.ts @@ -1,103 +1,103 @@ -import test from 'ava' +import { test, expect } from 'vitest' import fc from 'fast-check' import { ParseHexColor, HexDistance, HexRelativeLuminance, HexContrastRatio, IsReadableTextColor, TextReadabilityScore, IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring.js' -test('ParseHexColor accepts #RGB, #RRGGBB, and no-# forms', T => { - T.deepEqual(ParseHexColor('#fff'), [255, 255, 255]) - T.deepEqual(ParseHexColor('fff'), [255, 255, 255]) - T.deepEqual(ParseHexColor('#ffffff'), [255, 255, 255]) - T.deepEqual(ParseHexColor('ffffff'), [255, 255, 255]) - T.deepEqual(ParseHexColor('#1a2B3c'), [0x1a, 0x2b, 0x3c]) +test('ParseHexColor accepts #RGB, #RRGGBB, and no-# forms', () => { + expect(ParseHexColor('#fff')).toEqual([255, 255, 255]) + expect(ParseHexColor('fff')).toEqual([255, 255, 255]) + expect(ParseHexColor('#ffffff')).toEqual([255, 255, 255]) + expect(ParseHexColor('ffffff')).toEqual([255, 255, 255]) + expect(ParseHexColor('#1a2B3c')).toEqual([0x1a, 0x2b, 0x3c]) }) -test('ParseHexColor rejects malformed input', T => { - T.throws(() => ParseHexColor('#zzzzzz')) - T.throws(() => ParseHexColor('#12345')) - T.throws(() => ParseHexColor('')) +test('ParseHexColor rejects malformed input', () => { + expect(() => ParseHexColor('#zzzzzz')).toThrow() + expect(() => ParseHexColor('#12345')).toThrow() + expect(() => ParseHexColor('')).toThrow() }) -test('HexDistance is zero for identical colors and symmetric', T => { - T.is(HexDistance('#123456', '#123456'), 0) - T.is(HexDistance('#123456', '#abcdef'), HexDistance('#abcdef', '#123456')) +test('HexDistance is zero for identical colors and symmetric', () => { + expect(HexDistance('#123456', '#123456')).toBe(0) + expect(HexDistance('#123456', '#abcdef')).toBe(HexDistance('#abcdef', '#123456')) }) -test('HexDistance matches the Euclidean RGB-cube distance for black/white', T => { - T.true(Math.abs(HexDistance('#000000', '#ffffff') - Math.sqrt(255 ** 2 * 3)) < 1e-9) +test('HexDistance matches the Euclidean RGB-cube distance for black/white', () => { + expect(Math.abs(HexDistance('#000000', '#ffffff') - Math.sqrt(255 ** 2 * 3)) < 1e-9).toBe(true) }) -test('HexRelativeLuminance matches WCAG black and white endpoints', T => { - T.is(HexRelativeLuminance('#000'), 0) - T.is(HexRelativeLuminance('#fff'), 1) +test('HexRelativeLuminance matches WCAG black and white endpoints', () => { + expect(HexRelativeLuminance('#000')).toBe(0) + expect(HexRelativeLuminance('#fff')).toBe(1) }) -test('HexRelativeLuminance follows human-perception channel weights', T => { - T.true(HexRelativeLuminance('#00ff00') > HexRelativeLuminance('#ff0000')) - T.true(HexRelativeLuminance('#ff0000') > HexRelativeLuminance('#0000ff')) +test('HexRelativeLuminance follows human-perception channel weights', () => { + expect(HexRelativeLuminance('#00ff00') > HexRelativeLuminance('#ff0000')).toBe(true) + expect(HexRelativeLuminance('#ff0000') > HexRelativeLuminance('#0000ff')).toBe(true) }) -test('HexContrastRatio matches WCAG contrast ratio endpoints', T => { - T.is(HexContrastRatio('#000', '#fff'), 21) - T.is(HexContrastRatio('#123456', '#123456'), 1) - T.is(HexContrastRatio('#fff', '#000'), HexContrastRatio('#000', '#fff')) +test('HexContrastRatio matches WCAG contrast ratio endpoints', () => { + expect(HexContrastRatio('#000', '#fff')).toBe(21) + expect(HexContrastRatio('#123456', '#123456')).toBe(1) + expect(HexContrastRatio('#fff', '#000')).toBe(HexContrastRatio('#000', '#fff')) }) -test('IsReadableTextColor applies WCAG AA and AAA text thresholds', T => { - T.true(IsReadableTextColor('#767676', '#ffffff')) - T.false(IsReadableTextColor('#777777', '#ffffff')) - T.true(IsReadableTextColor('#777777', '#ffffff', { LargeText: true })) - T.false(IsReadableTextColor('#767676', '#ffffff', { Enhanced: true })) - T.true(IsReadableTextColor('#767676', '#ffffff', { LargeText: true, Enhanced: true })) +test('IsReadableTextColor applies WCAG AA and AAA text thresholds', () => { + expect(IsReadableTextColor('#767676', '#ffffff')).toBe(true) + expect(IsReadableTextColor('#777777', '#ffffff')).toBe(false) + expect(IsReadableTextColor('#777777', '#ffffff', { LargeText: true })).toBe(true) + expect(IsReadableTextColor('#767676', '#ffffff', { Enhanced: true })).toBe(false) + expect(IsReadableTextColor('#767676', '#ffffff', { LargeText: true, Enhanced: true })).toBe(true) }) -test('TextReadabilityScore normalizes contrast ratio for ranking text colors', T => { - T.is(TextReadabilityScore('#000', '#000'), 0) - T.is(TextReadabilityScore('#000', '#fff'), 1) - T.true(TextReadabilityScore('#444444', '#ffffff') > TextReadabilityScore('#777777', '#ffffff')) +test('TextReadabilityScore normalizes contrast ratio for ranking text colors', () => { + expect(TextReadabilityScore('#000', '#000')).toBe(0) + expect(TextReadabilityScore('#000', '#fff')).toBe(1) + expect(TextReadabilityScore('#444444', '#ffffff') > TextReadabilityScore('#777777', '#ffffff')).toBe(true) }) -test('text readability helpers reject malformed HEX input', T => { - T.throws(() => HexRelativeLuminance('#zzzzzz')) - T.throws(() => HexContrastRatio('#000', '#12345')) - T.throws(() => IsReadableTextColor('', '#fff')) - T.throws(() => TextReadabilityScore('#000', '')) +test('text readability helpers reject malformed HEX input', () => { + expect(() => HexRelativeLuminance('#zzzzzz')).toThrow() + expect(() => HexContrastRatio('#000', '#12345')).toThrow() + expect(() => IsReadableTextColor('', '#fff')).toThrow() + expect(() => TextReadabilityScore('#000', '')).toThrow() }) -test('IsInsideRegion treats a single-point region as an exact match', T => { +test('IsInsideRegion treats a single-point region as an exact match', () => { const Region = ['#808080'] - T.true(IsInsideRegion('#808080', Region)) - T.false(IsInsideRegion('#808081', Region)) + expect(IsInsideRegion('#808080', Region)).toBe(true) + expect(IsInsideRegion('#808081', Region)).toBe(false) }) -test('IsInsideRegion handles a collinear (line) region', T => { +test('IsInsideRegion handles a collinear (line) region', () => { const Region = ['#000000', '#ffffff'] - T.true(IsInsideRegion('#000000', Region)) - T.true(IsInsideRegion('#ffffff', Region)) - T.true(IsInsideRegion('#404040', Region)) - T.false(IsInsideRegion('#ff0000', Region)) + expect(IsInsideRegion('#000000', Region)).toBe(true) + expect(IsInsideRegion('#ffffff', Region)).toBe(true) + expect(IsInsideRegion('#404040', Region)).toBe(true) + expect(IsInsideRegion('#ff0000', Region)).toBe(false) }) -test('IsInsideRegion handles a coplanar (polygon) region', T => { +test('IsInsideRegion handles a coplanar (polygon) region', () => { // Square at B=0: R,G both within [50, 200] const Square = ['#323200', '#c83200', '#c8c800', '#32c800'] - T.true(IsInsideRegion('#646400', Square)) - T.true(IsInsideRegion('#323200', Square)) - T.false(IsInsideRegion('#0a0a00', Square)) - T.false(IsInsideRegion('#64640a', Square)) + expect(IsInsideRegion('#646400', Square)).toBe(true) + expect(IsInsideRegion('#323200', Square)).toBe(true) + expect(IsInsideRegion('#0a0a00', Square)).toBe(false) + expect(IsInsideRegion('#64640a', Square)).toBe(false) }) -test('IsInsideRegion handles a volumetric (cube) region', T => { +test('IsInsideRegion handles a volumetric (cube) region', () => { const Cube = [ '#323232', '#c83232', '#32c832', '#3232c8', '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', ] - T.true(IsInsideRegion('#7d7d7d', Cube)) - T.true(IsInsideRegion('#323232', Cube)) - T.false(IsInsideRegion('#fafafa', Cube)) + expect(IsInsideRegion('#7d7d7d', Cube)).toBe(true) + expect(IsInsideRegion('#323232', Cube)).toBe(true) + expect(IsInsideRegion('#fafafa', Cube)).toBe(false) }) -test('IsInsideRegion always accepts convex combinations of RegionPoints', T => { +test('IsInsideRegion always accepts convex combinations of RegionPoints', () => { const Cube = [ '#323232', '#c83232', '#32c832', '#3232c8', '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', @@ -119,54 +119,54 @@ test('IsInsideRegion always accepts convex combinations of RegionPoints', T => { return IsInsideRegion(Hex, Cube) })) - T.pass() + expect(true).toBe(true) }) -test('RegionCentroidRatio is 1 at a single-point region and -1 elsewhere', T => { +test('RegionCentroidRatio is 1 at a single-point region and -1 elsewhere', () => { const Region = ['#808080'] - T.is(RegionCentroidRatio('#808080', Region), 1) - T.is(RegionCentroidRatio('#808081', Region), -1) + expect(RegionCentroidRatio('#808080', Region)).toBe(1) + expect(RegionCentroidRatio('#808081', Region)).toBe(-1) }) -test('RegionCentroidRatio uses the midpoint of a collinear hull instead of the point average', T => { +test('RegionCentroidRatio uses the midpoint of a collinear hull instead of the point average', () => { const Region = ['#000000', '#0a0a0a', '#c8c8c8'] - T.is(RegionCentroidRatio('#646464', Region), 1) - T.not(RegionCentroidRatio('#464646', Region), 1) + expect(RegionCentroidRatio('#646464', Region)).toBe(1) + expect(RegionCentroidRatio('#464646', Region)).not.toBe(1) }) -test('RegionCentroidRatio uses the area centroid of a polygon instead of the point average', T => { +test('RegionCentroidRatio uses the area centroid of a polygon instead of the point average', () => { const Region = ['#000000', '#c80000', '#c8c800', '#00c800', '#006400'] - T.is(RegionCentroidRatio('#646400', Region), 1) - T.not(RegionCentroidRatio('#506400', Region), 1) + expect(RegionCentroidRatio('#646400', Region)).toBe(1) + expect(RegionCentroidRatio('#506400', Region)).not.toBe(1) }) -test('RegionCentroidRatio uses the divergence-theorem volume centroid of a pyramid', T => { +test('RegionCentroidRatio uses the divergence-theorem volume centroid of a pyramid', () => { const Pyramid = ['#000000', '#c80000', '#c8c800', '#00c800', '#6464c8'] - T.is(RegionCentroidRatio('#646432', Pyramid), 1) - T.not(RegionCentroidRatio('#646428', Pyramid), 1) + expect(RegionCentroidRatio('#646432', Pyramid)).toBe(1) + expect(RegionCentroidRatio('#646428', Pyramid)).not.toBe(1) }) -test('RegionCentroidRatio ignores repeated points when finding a volume centroid', T => { +test('RegionCentroidRatio ignores repeated points when finding a volume centroid', () => { const Pyramid = ['#000000', '#c80000', '#c8c800', '#00c800', '#6464c8', '#6464c8'] - T.is(RegionCentroidRatio('#646432', Pyramid), 1) + expect(RegionCentroidRatio('#646432', Pyramid)).toBe(1) }) -test('RegionCentroidRatio is 1 at the centroid, 0 on the boundary, and -1 outside a cube region', T => { +test('RegionCentroidRatio is 1 at the centroid, 0 on the boundary, and -1 outside a cube region', () => { const Cube = [ '#323232', '#c83232', '#32c832', '#3232c8', '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', ] - T.is(RegionCentroidRatio('#7d7d7d', Cube), 1) - T.true(Math.abs(RegionCentroidRatio('#c8c8c8', Cube)) < 1e-6) - T.is(RegionCentroidRatio('#fafafa', Cube), -1) + expect(RegionCentroidRatio('#7d7d7d', Cube)).toBe(1) + expect(Math.abs(RegionCentroidRatio('#c8c8c8', Cube)) < 1e-6).toBe(true) + expect(RegionCentroidRatio('#fafafa', Cube)).toBe(-1) }) -test('RegionCentroidRatio decreases monotonically from centroid towards the boundary', T => { +test('RegionCentroidRatio decreases monotonically from centroid towards the boundary', () => { const Cube = [ '#323232', '#c83232', '#32c832', '#3232c8', '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', @@ -176,7 +176,7 @@ test('RegionCentroidRatio decreases monotonically from centroid towards the boun const Middle = RegionCentroidRatio('#b5b5b5', Cube) const Far = RegionCentroidRatio('#c3c3c3', Cube) - T.true(Near > Middle) - T.true(Middle > Far) - T.true(Far > 0) + expect(Near > Middle).toBe(true) + expect(Middle > Far).toBe(true) + expect(Far > 0).toBe(true) }) diff --git a/testunit/tests/index.test.ts b/testunit/tests/index.test.ts index 9721c41..0a7c26b 100644 --- a/testunit/tests/index.test.ts +++ b/testunit/tests/index.test.ts @@ -1,7 +1,7 @@ -import test from 'ava' +import { test, expect } from 'vitest' import { MatchSchema, AddrSchema, MatchValueSchema, AddrValueSchema, ParsePath, AsPathValue, SetValueAtPath, DeleteValueAtPath } from '@userscript/startrick.js' -test('returns JSONPaths for matching values at matching structural paths', T => { +test('returns JSONPaths for matching values at matching structural paths', () => { const Value = { profile: { name: 'Ada' }, tags: ['wiki'], @@ -15,11 +15,11 @@ test('returns JSONPaths for matching values at matching structural paths', T => ignored: /^missing$/, } - T.deepEqual(MatchSchema(Value, Schema), ['$.profile.name', '$.tags[0]', '$[\'display-name\']']) - T.true(AddrSchema(Value, Schema)) + expect(MatchSchema(Value, Schema)).toEqual(['$.profile.name', '$.tags[0]', '$[\'display-name\']']) + expect(AddrSchema(Value, Schema)).toBe(true) }) -test('matches safely stringifiable primitive values only', T => { +test('matches safely stringifiable primitive values only', () => { const UnsafeObject = { toString(): never { throw new Error('String must not be called') }, valueOf(): never { throw new Error('valueOf must not be called') }, @@ -47,12 +47,12 @@ test('matches safely stringifiable primitive values only', T => { fn: /^text$/, } - T.deepEqual(MatchSchema(Value, Schema), [ + expect(MatchSchema(Value, Schema)).toEqual([ '$.text', '$.number', '$.boolean', '$.bigint', '$.symbol', '$.nil', '$.undefined', ]) }) -test('handles nesting deeper than the call stack', T => { +test('handles nesting deeper than the call stack', () => { const Depth = 20_000 let Value: Record = { leaf: 'target' } let Schema: Record = { leaf: /^target$/ } @@ -62,48 +62,45 @@ test('handles nesting deeper than the call stack', T => { Schema = { next: Schema } } - T.deepEqual(MatchSchema(Value, Schema), [`$${'.next'.repeat(Depth)}.leaf`]) + expect(MatchSchema(Value, Schema)).toEqual([`$${'.next'.repeat(Depth)}.leaf`]) }) -test('requires the value and schema to share a path', T => { - T.deepEqual( - MatchSchema({ value: { target: 'match' } }, { value: { other: /^match$/ } }), - [], - ) +test('requires the value and schema to share a path', () => { + expect(MatchSchema({ value: { target: 'match' } }, { value: { other: /^match$/ } })).toEqual([]) }) -test('MatchValueSchema matches regardless of randomized property names/order', T => { +test('MatchValueSchema matches regardless of randomized property names/order', () => { const Schemas = [/^[0-9]{8,12}$/, /^[01]$/, /^host\.example\.com$/] const Value = { x9f2: '1', qz1: 'host.example.com', a: '123456789' } - T.deepEqual(MatchValueSchema(Value, Schemas).sort(), ['$.a', '$.qz1', '$.x9f2']) - T.true(AddrValueSchema(Value, Schemas)) + expect(MatchValueSchema(Value, Schemas).sort()).toEqual(['$.a', '$.qz1', '$.x9f2']) + expect(AddrValueSchema(Value, Schemas)).toBe(true) }) -test('MatchValueSchema containment mode allows extra properties', T => { +test('MatchValueSchema containment mode allows extra properties', () => { const Schemas = [/^[0-9]{8,12}$/, /^[01]$/] const Value = { a: '123456789', b: '1', c: 'extra', d: 'more-extra' } - T.deepEqual(MatchValueSchema(Value, Schemas).sort(), ['$.a', '$.b']) - T.deepEqual(MatchValueSchema(Value, Schemas, { Exact: true }), []) + expect(MatchValueSchema(Value, Schemas).sort()).toEqual(['$.a', '$.b']) + expect(MatchValueSchema(Value, Schemas, { Exact: true })).toEqual([]) }) -test('MatchValueSchema exact mode requires the same number of properties as schemas', T => { +test('MatchValueSchema exact mode requires the same number of properties as schemas', () => { const Schemas = [/^[0-9]{8,12}$/, /^[01]$/] const Value = { a: '123456789', b: '1' } - T.deepEqual(MatchValueSchema(Value, Schemas, { Exact: true }).sort(), ['$.a', '$.b']) + expect(MatchValueSchema(Value, Schemas, { Exact: true }).sort()).toEqual(['$.a', '$.b']) }) -test('MatchValueSchema requires a distinct value per regex (no reuse via bipartite matching)', T => { +test('MatchValueSchema requires a distinct value per regex (no reuse via bipartite matching)', () => { // Only one value ("1") can satisfy /^[01]$/, but two schemas require it - no perfect matching exists. const Schemas = [/^[01]$/, /^[01]$/] const Value = { a: '1', b: 'not-a-flag' } - T.deepEqual(MatchValueSchema(Value, Schemas), []) + expect(MatchValueSchema(Value, Schemas)).toEqual([]) }) -test('MatchValueSchema matches nested SSR values inside randomized object properties', T => { +test('MatchValueSchema matches nested SSR values inside randomized object properties', () => { const Image = /\/\/i\.namu\.wiki\/i\/[a-zA-Z0-9-_]+\.[a-z]{3,4}/ const Schemas = [ [Image, Image, Image, Image, Image], @@ -123,52 +120,52 @@ test('MatchValueSchema matches nested SSR values inside randomized object proper secondImages: ['//i.namu.wiki/i/six.png', '//i.namu.wiki/i/seven.svg'], } - T.deepEqual(MatchValueSchema(Value, Schemas).sort(), ['$.ads', '$.firstImages', '$.secondImages']) - T.deepEqual(MatchValueSchema({ ...Value, ads: [{ title: 'only-korean', labels: [{ text: '광고' }] }] }, Schemas), []) + expect(MatchValueSchema(Value, Schemas).sort()).toEqual(['$.ads', '$.firstImages', '$.secondImages']) + expect(MatchValueSchema({ ...Value, ads: [{ title: 'only-korean', labels: [{ text: '광고' }] }] }, Schemas)).toEqual([]) }) -test('ParsePath parses identifier, index, and escaped-key segments produced by AddPathSegment', T => { - T.deepEqual(ParsePath('$'), []) - T.deepEqual(ParsePath('$.profile.name'), ['profile', 'name']) - T.deepEqual(ParsePath('$.tags[0]'), ['tags', 0]) - T.deepEqual(ParsePath('$[\'display-name\']'), ['display-name']) - T.deepEqual(ParsePath('$[\'it\\\'s\\\\here\']'), ['it\'s\\here']) +test('ParsePath parses identifier, index, and escaped-key segments produced by AddPathSegment', () => { + expect(ParsePath('$')).toEqual([]) + expect(ParsePath('$.profile.name')).toEqual(['profile', 'name']) + expect(ParsePath('$.tags[0]')).toEqual(['tags', 0]) + expect(ParsePath('$[\'display-name\']')).toEqual(['display-name']) + expect(ParsePath('$[\'it\\\'s\\\\here\']')).toEqual(['it\'s\\here']) }) -test('ParsePath rejects malformed paths', T => { - T.throws(() => ParsePath('profile.name')) - T.throws(() => ParsePath('$.profile..name')) - T.throws(() => ParsePath('$.9invalid')) +test('ParsePath rejects malformed paths', () => { + expect(() => ParsePath('profile.name')).toThrow() + expect(() => ParsePath('$.profile..name')).toThrow() + expect(() => ParsePath('$.9invalid')).toThrow() }) -test('SetValueAtPath replaces an existing value without mutating the original', T => { +test('SetValueAtPath replaces an existing value without mutating the original', () => { const Value = { profile: { name: 'Ada' } } const Result = SetValueAtPath(Value, '$.profile.name', 'Grace') - T.deepEqual(Result, { profile: { name: 'Grace' } }) - T.is(Value.profile.name, 'Ada') + expect(Result).toEqual({ profile: { name: 'Grace' } }) + expect(Value.profile.name).toBe('Ada') }) -test('SetValueAtPath auto-creates missing intermediate objects and arrays', T => { +test('SetValueAtPath auto-creates missing intermediate objects and arrays', () => { const Result = SetValueAtPath<{ A: { B: { C: string }[] } }>({}, '$.A.B[2].C', 'target') - T.deepEqual(Result, { A: { B: [undefined, undefined, { C: 'target' }] } }) + expect(Result).toEqual({ A: { B: [undefined, undefined, { C: 'target' }] } }) }) -test('SetValueAtPath supports an updater function based on the old value', T => { +test('SetValueAtPath supports an updater function based on the old value', () => { const Value = { count: 1 } const Result = SetValueAtPath(Value, '$.count', (Old: unknown) => (Old as number) + 1) - T.deepEqual(Result, { count: 2 }) + expect(Result).toEqual({ count: 2 }) }) -test('SetValueAtPath passes the original value and type to updater functions', T => { +test('SetValueAtPath passes the original value and type to updater functions', () => { const Value = { flag: 1 } const Result = SetValueAtPath(Value, '$.flag', (Old: unknown) => (typeof Old === 'number' && Old === 1 ? 0 : Old)) - T.deepEqual(Result, { flag: 0 }) - T.is(Value.flag, 1) + expect(Result).toEqual({ flag: 0 }) + expect(Value.flag).toBe(1) }) -test('SetValueAtPath passes the property Key and target Path to updater functions', T => { +test('SetValueAtPath passes the property Key and target Path to updater functions', () => { const Value = { profile: { name: 'Ada' } } let ReceivedKey: string | number | undefined let ReceivedPath: string | undefined @@ -179,11 +176,11 @@ test('SetValueAtPath passes the property Key and target Path to updater function return Old }) - T.is(ReceivedKey, 'name') - T.is(ReceivedPath, '$.profile.name') + expect(ReceivedKey).toBe('name') + expect(ReceivedPath).toBe('$.profile.name') }) -test('SetValueAtPath passes the array index as Key to updater functions', T => { +test('SetValueAtPath passes the array index as Key to updater functions', () => { const Value = { list: ['a', 'b'] } let ReceivedKey: string | number | undefined @@ -192,10 +189,10 @@ test('SetValueAtPath passes the array index as Key to updater functions', T => { return Old }) - T.is(ReceivedKey, 1) + expect(ReceivedKey).toBe(1) }) -test('SetValueAtPath passes an undefined Key for the root path', T => { +test('SetValueAtPath passes an undefined Key for the root path', () => { let ReceivedKey: string | number | undefined SetValueAtPath({ old: true }, '$', (Old: unknown, Key: string | number | undefined) => { @@ -203,53 +200,53 @@ test('SetValueAtPath passes an undefined Key for the root path', T => { return Old }) - T.is(ReceivedKey, undefined) + expect(ReceivedKey).toBe(undefined) }) -test('SetValueAtPath sets a function when it is wrapped as an explicit value', T => { +test('SetValueAtPath sets a function when it is wrapped as an explicit value', () => { const Handler = (): string => 'handled' const Result = SetValueAtPath<{ handler: () => string }>({}, '$.handler', AsPathValue(Handler)) - T.is(Result.handler, Handler) - T.is(Result.handler(), 'handled') + expect(Result.handler).toBe(Handler) + expect(Result.handler()).toBe('handled') }) -test('SetValueAtPath handles escaped-key paths', T => { +test('SetValueAtPath handles escaped-key paths', () => { const Value = { 'display-name': 'NamuLink' } const Result = SetValueAtPath(Value, '$[\'display-name\']', 'Renamed') - T.deepEqual(Result, { 'display-name': 'Renamed' }) + expect(Result).toEqual({ 'display-name': 'Renamed' }) }) -test('SetValueAtPath replaces the whole root when given the root path', T => { - T.deepEqual(SetValueAtPath({ old: true }, '$', { fresh: true }), { fresh: true }) +test('SetValueAtPath replaces the whole root when given the root path', () => { + expect(SetValueAtPath({ old: true }, '$', { fresh: true })).toEqual({ fresh: true }) }) -test('SetValueAtPath followed by MatchSchema on the produced path round-trips', T => { +test('SetValueAtPath followed by MatchSchema on the produced path round-trips', () => { const Value = { profile: { name: 'Ada' }, tags: ['wiki'] } const Schema = { profile: { name: /^Ada$/ } } const [Path] = MatchSchema(Value, Schema) const Updated = SetValueAtPath(Value, Path, 'Grace') - T.deepEqual(Updated, { profile: { name: 'Grace' }, tags: ['wiki'] }) + expect(Updated).toEqual({ profile: { name: 'Grace' }, tags: ['wiki'] }) }) -test('DeleteValueAtPath removes an object property without mutating the original', T => { +test('DeleteValueAtPath removes an object property without mutating the original', () => { const Value = { profile: { name: 'Ada', role: 'admin' } } const Result = DeleteValueAtPath(Value, '$.profile.role') - T.deepEqual(Result, { profile: { name: 'Ada' } }) - T.is(Value.profile.role, 'admin') + expect(Result).toEqual({ profile: { name: 'Ada' } }) + expect(Value.profile.role).toBe('admin') }) -test('DeleteValueAtPath splices out an array element, shifting later indices', T => { +test('DeleteValueAtPath splices out an array element, shifting later indices', () => { const Value = { tags: ['a', 'b', 'c'] } const Result = DeleteValueAtPath(Value, '$.tags[1]') - T.deepEqual(Result, { tags: ['a', 'c'] }) - T.deepEqual(Value.tags, ['a', 'b', 'c']) + expect(Result).toEqual({ tags: ['a', 'c'] }) + expect(Value.tags).toEqual(['a', 'b', 'c']) }) -test('DeleteValueAtPath is a no-op when an intermediate path segment does not exist', T => { +test('DeleteValueAtPath is a no-op when an intermediate path segment does not exist', () => { const Value = { profile: { name: 'Ada' } } - T.deepEqual(DeleteValueAtPath(Value, '$.missing.name'), Value) + expect(DeleteValueAtPath(Value, '$.missing.name')).toEqual(Value) }) \ No newline at end of file diff --git a/testunit/vitest.config.ts b/testunit/vitest.config.ts new file mode 100644 index 0000000..a4c862a --- /dev/null +++ b/testunit/vitest.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@userscript/': fileURLToPath(new URL('../userscript/source/', import.meta.url)) + } + }, + test: { + include: ['tests/**/*.test.ts'] + } +}) From ae726ca8aa096914c9496b3f7c50664fa1f93383 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Tue, 1 Sep 2026 05:16:41 +0000 Subject: [PATCH 10/16] feat: migrate from ESLint to Oxlint for linting and update related configurations --- .github/workflows/build.yml | 8 +- .oxlintrc.json | 77 ++++++++++++ builder/package.json | 10 +- eslint.config.js | 27 ---- oxlint-plugin.mjs | 188 ++++++++++++++++++++++++++++ package.json | 8 +- testunit/package.json | 9 +- userscript/package.json | 11 +- userscript/source/index.ts | 2 +- userscript/source/worker-runtime.ts | 6 +- 10 files changed, 290 insertions(+), 56 deletions(-) create mode 100644 .oxlintrc.json delete mode 100644 eslint.config.js create mode 100644 oxlint-plugin.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2e95e22..3147b23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,8 +8,8 @@ on: branches: [ "**" ] jobs: - eslint: - name: Run ESLint + lint: + name: Run lint runs-on: ubuntu-latest permissions: contents: read @@ -27,8 +27,8 @@ jobs: run: mkdir -p ~/.pnpm-store && pnpm config set store-dir ~/.pnpm-store - name: Install dependencies run: pnpm install --no-lockfile - - name: Run ESLint - run: npm run lint + - name: Run lint + run: pnpm run lint build: name: Build the project runs-on: ubuntu-latest diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..ce11aca --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,77 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [], + "jsPlugins": ["./oxlint-plugin.mjs"], + "categories": { + "correctness": "off" + }, + "env": { + "builtin": true + }, + "options": { + "typeAware": true + }, + "rules": { + "namulink/pascal-case": "error", + "namulink/no-semicolons": "error", + "namulink/single-quotes": "error" + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx"], + "plugins": ["typescript"], + "rules": { + "no-array-constructor": "error", + "no-unused-expressions": "error", + "no-unused-vars": "warn", + "typescript/ban-ts-comment": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-explicit-any": "error", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-misused-new": "error", + "typescript/no-namespace": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-require-imports": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/triple-slash-reference": "error", + "typescript/await-thenable": "warn", + "typescript/no-array-delete": "warn", + "typescript/no-base-to-string": "warn", + "typescript/no-duplicate-type-constituents": "warn", + "typescript/no-floating-promises": "warn", + "typescript/no-for-in-array": "warn", + "typescript/no-implied-eval": "warn", + "typescript/no-misused-promises": "warn", + "typescript/no-redundant-type-constituents": "warn", + "typescript/no-unnecessary-type-assertion": "warn", + "typescript/no-unsafe-argument": "warn", + "typescript/no-unsafe-assignment": "warn", + "typescript/no-unsafe-call": "warn", + "typescript/no-unsafe-enum-comparison": "warn", + "typescript/no-unsafe-member-access": "warn", + "typescript/no-unsafe-return": "warn", + "typescript/no-unsafe-unary-minus": "warn", + "typescript/only-throw-error": "warn", + "typescript/prefer-promise-reject-errors": "warn", + "typescript/require-await": "warn", + "typescript/restrict-plus-operands": "warn", + "typescript/restrict-template-expressions": "warn", + "typescript/unbound-method": "warn" + } + }, + { + "files": ["tests/**/*.ts"], + "plugins": ["typescript"], + "rules": { + "typescript/no-floating-promises": "off" + } + } + ] +} diff --git a/builder/package.json b/builder/package.json index 4064acc..ac3c88d 100644 --- a/builder/package.json +++ b/builder/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "lint": "tsc --noEmit && eslint **/*.ts", + "lint": "tsc --noEmit && oxlint --type-aware **/*.ts", "build": "tsx source/buildci.ts", "debug": "tsx source/debug.ts", "clean": "rm -rf dist && rm -rf .buildcache" @@ -12,18 +12,16 @@ "@npmcli/package-json": "^8.0.0", "@types/node": "^24.12.4", "@types/npmcli__package-json": "^4.0.4", - "@typescript-eslint/eslint-plugin": "^8.59.4", - "@typescript-eslint/parser": "^8.59.4", "@typescriptprime/parsing": "^2.0.1", "chokidar": "^5.0.0", "esbuild": "^0.28.0", - "eslint": "^10.4.0", "memfs": "^4.57.2", + "oxlint": "^1.79.0", + "oxlint-tsgolint": "^7.0.2001", "tldts": "^7.1.2", "ts-morph": "^28.0.0", "tsx": "^4.22.3", - "typescript": "^6.0.3", - "typescript-eslint": "^8.59.4", + "typescript": "^7.0.2", "zod": "^4.4.3" } } diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 31728f2..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,27 +0,0 @@ -import tsPlugin from "@typescript-eslint/eslint-plugin" -import tsParser from "@typescript-eslint/parser" - -const config = [ - { - files: ["**/*.ts", "**/*.tsx"], // Target TypeScript files - languageOptions: { - parser: tsParser, - sourceType: "module", - }, - plugins: { - "@typescript-eslint": tsPlugin, - }, - rules: { - ...tsPlugin.configs.recommended.rules, - "semi": ["error", "never"], - "quotes": ["error", "single"], - "@typescript-eslint/no-unused-vars": "warn", - '@typescript-eslint/naming-convention': ['error', { - selector: ['variableLike', 'parameterProperty', 'classProperty', 'typeProperty'], - format: ['PascalCase'] - }] - } - } -] - -export default config \ No newline at end of file diff --git a/oxlint-plugin.mjs b/oxlint-plugin.mjs new file mode 100644 index 0000000..2b3d637 --- /dev/null +++ b/oxlint-plugin.mjs @@ -0,0 +1,188 @@ +const RuleMeta = Message => ({ + type: 'suggestion', + docs: { + description: Message, + }, + schema: [], +}) + +const IsPascalCase = Name => Name.length === 0 || (Name[0] === Name[0].toUpperCase() && !Name.includes('_')) + +const ReportName = (Context, Node) => { + const Name = Node?.type === 'Identifier' || Node?.type === 'PrivateIdentifier' + ? Node.name + : typeof Node?.value === 'string' ? Node.value : undefined + + if (Name !== undefined && !IsPascalCase(Name)) { + Context.report({ + node: Node, + message: `Identifier '${Name}' must be in PascalCase`, + }) + } +} + +const CheckBinding = (Context, Pattern) => { + if (!Pattern) return + + switch (Pattern.type) { + case 'Identifier': + ReportName(Context, Pattern) + break + case 'AssignmentPattern': + CheckBinding(Context, Pattern.left) + break + case 'ArrayPattern': + for (const Element of Pattern.elements) CheckBinding(Context, Element) + break + case 'ObjectPattern': + for (const Property of Pattern.properties) { + if (Property.type === 'RestElement') CheckBinding(Context, Property.argument) + else CheckBinding(Context, Property.value) + } + break + case 'RestElement': + CheckBinding(Context, Pattern.argument) + break + case 'TSParameterProperty': + CheckBinding(Context, Pattern.parameter) + break + } +} + +const CheckProperty = (Context, Node) => { + if (Node.kind === 'constructor') return + if (!Node.computed) ReportName(Context, Node.key) +} + +const PascalCaseRule = { + meta: RuleMeta('Require PascalCase for variable-like, parameter-property, class-property, and type-property declarations'), + create(Context) { + const CheckFunction = Node => { + ReportName(Context, Node.id) + for (const Parameter of Node.params) { + if (Parameter.type !== 'TSParameterProperty') CheckBinding(Context, Parameter) + } + } + + return { + VariableDeclarator(Node) { + CheckBinding(Context, Node.id) + }, + FunctionDeclaration: CheckFunction, + FunctionExpression: CheckFunction, + TSDeclareFunction: CheckFunction, + TSEmptyBodyFunctionExpression: CheckFunction, + ArrowFunctionExpression(Node) { + for (const Parameter of Node.params) CheckBinding(Context, Parameter) + }, + PropertyDefinition(Node) { + CheckProperty(Context, Node) + }, + TSAbstractPropertyDefinition(Node) { + CheckProperty(Context, Node) + }, + TSParameterProperty(Node) { + CheckBinding(Context, Node.parameter) + }, + TSPropertySignature(Node) { + CheckProperty(Context, Node) + }, + } + }, +} + +const NoSemicolonsRule = { + meta: RuleMeta('Disallow optional semicolons'), + create(Context) { + const SourceCode = Context.sourceCode + const UnsafeClassFieldNames = new Set(['get', 'set', 'static']) + const UnsafeClassFieldFollowers = new Set(['*', 'in', 'instanceof']) + + const IsClassFieldHazard = Node => { + if (Node.type !== 'PropertyDefinition') return false + + if (!Node.computed && Node.key.type === 'Identifier' && UnsafeClassFieldNames.has(Node.key.name)) { + const IsStaticStatic = Node.static && Node.key.name === 'static' + if (!IsStaticStatic && !Node.value) return true + } + + return UnsafeClassFieldFollowers.has(SourceCode.getTokenAfter(Node)?.value) + } + + const CanRemoveSemicolon = Node => { + const Tokens = SourceCode.getTokens(Node) + const Semicolon = Tokens.at(-1) + if (Semicolon?.value !== ';') return false + + const NextToken = SourceCode.getTokenAfter(Node) + if (!NextToken || NextToken.value === '}' || NextToken.value === ';') return true + if (IsClassFieldHazard(Node)) return false + + const PreviousToken = Tokens.at(-2) + if (PreviousToken && PreviousToken.loc.end.line === NextToken.loc.start.line) return false + + return !/^[-[(/+`]/u.test(NextToken.value) || NextToken.value === '++' || NextToken.value === '--' + } + + const Check = Node => { + if (!CanRemoveSemicolon(Node)) return + Context.report({ node: SourceCode.getLastToken(Node), message: 'Unnecessary semicolon' }) + } + + const CheckVariable = Node => { + const Parent = Node.parent + if ((Parent.type === 'ForStatement' && Parent.init === Node) + || (/^For(?:In|Of)Statement$/u.test(Parent.type) && Parent.left === Node)) return + Check(Node) + } + + return { + VariableDeclaration: CheckVariable, + ExpressionStatement: Check, + ReturnStatement: Check, + ThrowStatement: Check, + DoWhileStatement: Check, + DebuggerStatement: Check, + BreakStatement: Check, + ContinueStatement: Check, + ImportDeclaration: Check, + ExportAllDeclaration: Check, + ExportNamedDeclaration(Node) { + if (!Node.declaration) Check(Node) + }, + ExportDefaultDeclaration(Node) { + if (!/(?:Class|Function)Declaration$/u.test(Node.declaration.type)) Check(Node) + }, + PropertyDefinition: Check, + } + }, +} + +const SingleQuotesRule = { + meta: RuleMeta('Require single quotes for string literals'), + create(Context) { + return { + Literal(Node) { + if (typeof Node.value === 'string' && Context.sourceCode.getText(Node).startsWith('"')) { + Context.report({ node: Node, message: 'Strings must use single quotes' }) + } + }, + TemplateLiteral(Node) { + if (Node.expressions.length === 0 && Node.parent?.type !== 'TaggedTemplateExpression') { + Context.report({ node: Node, message: 'Strings must use single quotes' }) + } + }, + } + }, +} + +export default { + meta: { + name: 'namulink', + }, + rules: { + 'pascal-case': PascalCaseRule, + 'no-semicolons': NoSemicolonsRule, + 'single-quotes': SingleQuotesRule, + }, +} diff --git a/package.json b/package.json index a8894cd..715f6c9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build:stable": "npm run build -w builder -- --minify true --use-cache false --build-type production --SubscriptionUrl https://cdn.jsdelivr.net/npm/@filteringdev/namulink@latest/dist/NamuLink.user.js", "build:dev": "npm run build -w builder -- --minify false --use-cache false --build-type production --SubscriptionUrl https://cdn.jsdelivr.net/npm/@filteringdev/namulink@latest/dist/NamuLink.user.js", "debug": "npm run debug -w builder", - "lint": "npm run lint -w builder && npm run lint -w userscript", + "lint": "pnpm --filter @filteringdev/namulink-builder run lint && pnpm --filter @filteringdev/namulink-userscript run lint", "test": "npm run test -w testunit" }, "keywords": [ @@ -28,9 +28,7 @@ "testunit" ], "devDependencies": { - "@typescript-eslint/eslint-plugin": "^8.59.4", - "@typescript-eslint/parser": "^8.59.4", - "eslint": "^10.4.0", - "typescript-eslint": "^8.59.4" + "oxlint": "^1.79.0", + "oxlint-tsgolint": "^7.0.2001" } } diff --git a/testunit/package.json b/testunit/package.json index e77d69f..7b56341 100644 --- a/testunit/package.json +++ b/testunit/package.json @@ -3,19 +3,18 @@ "private": true, "type": "module", "scripts": { - "lint": "tsc --noEmit && eslint **/*.ts", + "lint": "tsc --noEmit && oxlint --type-aware **/*.ts", "test": "vitest run" }, "devDependencies": { "@types/node": "^24.13.1", "@types/web": "^0.0.345", - "@typescript-eslint/eslint-plugin": "^8.59.4", - "@typescript-eslint/parser": "^8.59.4", "@violentmonkey/types": "^0.3.3", "esbuild": "^0.28.0", - "eslint": "^10.4.0", "fast-check": "^4.9.0", - "typescript-eslint": "^8.59.4", + "oxlint": "^1.79.0", + "oxlint-tsgolint": "^7.0.2001", + "typescript": "^7.0.2", "vitest": "^3.2.4" } } diff --git a/userscript/package.json b/userscript/package.json index 3cf8909..803eb4c 100644 --- a/userscript/package.json +++ b/userscript/package.json @@ -3,14 +3,15 @@ "private": true, "type": "module", "scripts": { - "lint": "tsc --noEmit && eslint **/*.ts" + "lint": "tsc --noEmit && oxlint --type-aware **/*.ts" }, "devDependencies": { "@types/web": "^0.0.345", - "@typescript-eslint/eslint-plugin": "^8.59.4", - "@typescript-eslint/parser": "^8.59.4", "@violentmonkey/types": "^0.3.3", - "eslint": "^10.4.0", - "typescript-eslint": "^8.59.4" + "oxlint": "^1.79.0", + "oxlint-tsgolint": "^7.0.2001" + }, + "dependencies": { + "typescript": "^7.0.2" } } diff --git a/userscript/source/index.ts b/userscript/source/index.ts index 8618246..aae70ed 100644 --- a/userscript/source/index.ts +++ b/userscript/source/index.ts @@ -9,7 +9,7 @@ */ type unsafeWindow = typeof window -// eslint-disable-next-line @typescript-eslint/naming-convention +// oxlint-disable-next-line namulink/pascal-case declare const unsafeWindow: unsafeWindow import { MatchValueSchema, SetValueAtPath, type ValueSchema } from './startrick.js' diff --git a/userscript/source/worker-runtime.ts b/userscript/source/worker-runtime.ts index 9e858ae..2f81132 100644 --- a/userscript/source/worker-runtime.ts +++ b/userscript/source/worker-runtime.ts @@ -3,14 +3,14 @@ type NodeMessagePort = { on(EventName: 'message', Listener: (Value: unknown) => void): void postMessage(Value: unknown): void } -// eslint-disable-next-line @typescript-eslint/naming-convention -- must match Node's worker_threads Worker options shape +// oxlint-disable-next-line namulink/pascal-case -- must match Node's worker_threads Worker options shape type NodeWorkerConstructor = new (FileNameOrCode: string, Options?: { eval?: boolean }) => { on(EventName: 'message' | 'error', Listener: (Value: unknown) => void): void postMessage(Value: unknown): void terminate(): Promise } type NodeWorkerThreadsModule = { - // eslint-disable-next-line @typescript-eslint/naming-convention -- must match Node's worker_threads export name + // oxlint-disable-next-line namulink/pascal-case -- must match Node's worker_threads export name parentPort: NodeMessagePort | null Worker: NodeWorkerConstructor } @@ -45,7 +45,7 @@ export async function GetWorkerPort(): Promise { } } - // eslint-disable-next-line @typescript-eslint/naming-convention -- must match Node's worker_threads export name + // oxlint-disable-next-line namulink/pascal-case -- must match Node's worker_threads export name const { parentPort } = await ImportWorkerThreads() if (!parentPort) throw new Error('parentPort is unavailable outside a worker_threads worker') From 5392117998747cfbcb138b2f1f4c8d3668e43f01 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Tue, 1 Sep 2026 05:18:21 +0000 Subject: [PATCH 11/16] chore: move coloring lib --- userscript/source/{ => coloring}/coloring-client.ts | 0 userscript/source/{ => coloring}/coloring-types.ts | 0 userscript/source/{ => coloring}/coloring-worker.ts | 0 userscript/source/{ => coloring}/coloring.ts | 0 userscript/source/{ => coloring}/worker-runtime.ts | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename userscript/source/{ => coloring}/coloring-client.ts (100%) rename userscript/source/{ => coloring}/coloring-types.ts (100%) rename userscript/source/{ => coloring}/coloring-worker.ts (100%) rename userscript/source/{ => coloring}/coloring.ts (100%) rename userscript/source/{ => coloring}/worker-runtime.ts (100%) diff --git a/userscript/source/coloring-client.ts b/userscript/source/coloring/coloring-client.ts similarity index 100% rename from userscript/source/coloring-client.ts rename to userscript/source/coloring/coloring-client.ts diff --git a/userscript/source/coloring-types.ts b/userscript/source/coloring/coloring-types.ts similarity index 100% rename from userscript/source/coloring-types.ts rename to userscript/source/coloring/coloring-types.ts diff --git a/userscript/source/coloring-worker.ts b/userscript/source/coloring/coloring-worker.ts similarity index 100% rename from userscript/source/coloring-worker.ts rename to userscript/source/coloring/coloring-worker.ts diff --git a/userscript/source/coloring.ts b/userscript/source/coloring/coloring.ts similarity index 100% rename from userscript/source/coloring.ts rename to userscript/source/coloring/coloring.ts diff --git a/userscript/source/worker-runtime.ts b/userscript/source/coloring/worker-runtime.ts similarity index 100% rename from userscript/source/worker-runtime.ts rename to userscript/source/coloring/worker-runtime.ts From 89b9959630c1e31cbe7e10a535d87a173b7ea359 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Tue, 1 Sep 2026 05:21:06 +0000 Subject: [PATCH 12/16] chore: move `worker-runtime.ts` lib --- userscript/source/coloring/coloring-client.ts | 2 +- userscript/source/coloring/coloring-worker.ts | 2 +- userscript/source/{coloring => }/worker-runtime.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename userscript/source/{coloring => }/worker-runtime.ts (100%) diff --git a/userscript/source/coloring/coloring-client.ts b/userscript/source/coloring/coloring-client.ts index 57dc1d5..a654cc5 100644 --- a/userscript/source/coloring/coloring-client.ts +++ b/userscript/source/coloring/coloring-client.ts @@ -1,4 +1,4 @@ -import { CreateIsomorphicWorker, type WorkerLike } from './worker-runtime.js' +import { CreateIsomorphicWorker, type WorkerLike } from '../worker-runtime.js' import type { ColoringBatchItem, ColoringBatchRequest, ColoringBatchResponse, ColoringBatchResultValue } from './coloring-types.js' export type ColoringWorkerPool = { diff --git a/userscript/source/coloring/coloring-worker.ts b/userscript/source/coloring/coloring-worker.ts index 8a58a9b..d2914ae 100644 --- a/userscript/source/coloring/coloring-worker.ts +++ b/userscript/source/coloring/coloring-worker.ts @@ -1,5 +1,5 @@ import { IsInsideRegion, RegionCentroidRatio } from './coloring.js' -import { GetWorkerPort } from './worker-runtime.js' +import { GetWorkerPort } from '../worker-runtime.js' import type { ColoringBatchItem, ColoringBatchRequest, ColoringBatchResponse, ColoringBatchResultValue } from './coloring-types.js' function RunItem(Item: ColoringBatchItem): ColoringBatchResultValue { diff --git a/userscript/source/coloring/worker-runtime.ts b/userscript/source/worker-runtime.ts similarity index 100% rename from userscript/source/coloring/worker-runtime.ts rename to userscript/source/worker-runtime.ts From 73a4db803f25fa7362cc1d3500846066a876ffff Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Tue, 1 Sep 2026 05:26:47 +0000 Subject: [PATCH 13/16] fix: update entry point for coloring worker and adjust import path in tests --- builder/source/build.ts | 2 +- ...worker.test.ts => coloring-client.test.ts} | 51 +++++++++++++++---- testunit/tests/coloring.test.ts | 2 +- 3 files changed, 42 insertions(+), 13 deletions(-) rename testunit/tests/{coloring-worker.test.ts => coloring-client.test.ts} (59%) diff --git a/builder/source/build.ts b/builder/source/build.ts index 22660ee..d2c8088 100644 --- a/builder/source/build.ts +++ b/builder/source/build.ts @@ -112,7 +112,7 @@ export async function Build(OptionsParam?: BuildOptions): Promise { // Bundled separately (not inlined via the virtual entry) so it can be embedded as a string and run inside a Worker/worker_threads. const ColoringWorkerCode = await ESBuild.build({ - entryPoints: [Path.resolve(ProjectRoot, 'userscript', 'source', 'coloring-worker.ts')], + entryPoints: [Path.resolve(ProjectRoot, 'userscript', 'source', 'coloring', 'coloring-worker.ts')], bundle: true, minify: Options.Minify, write: false, diff --git a/testunit/tests/coloring-worker.test.ts b/testunit/tests/coloring-client.test.ts similarity index 59% rename from testunit/tests/coloring-worker.test.ts rename to testunit/tests/coloring-client.test.ts index b26479b..a44db2c 100644 --- a/testunit/tests/coloring-worker.test.ts +++ b/testunit/tests/coloring-client.test.ts @@ -1,9 +1,9 @@ import { test, expect } from 'vitest' import * as Path from 'node:path' import * as ESBuild from 'esbuild' -import { IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring.js' -import { CreateColoringWorkerPool } from '@userscript/coloring-client.js' -import type { ColoringBatchItem, ColoringBatchResultValue } from '@userscript/coloring-types.js' +import { IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring/coloring.js' +import { CreateColoringWorkerPool } from '@userscript/coloring/coloring-client.js' +import type { ColoringBatchItem, ColoringBatchResultValue } from '@userscript/coloring/coloring-types.js' const Regions: Record = { Grayscale: ['#000000', '#ffffff'], @@ -30,13 +30,8 @@ function RunDirectly(Items: ColoringBatchItem[]): ColoringBatchResultValue[] { : RegionCentroidRatio(Item.ComparePointHex, Item.RegionPoints)) } -test('coloring batch logic is deterministic when called directly (no worker)', () => { - const Items = BuildDataset() - expect(RunDirectly(Items)).toEqual(RunDirectly(Items)) -}) - -test('CreateColoringWorkerPool spreads a batch across worker_threads and matches direct-call results', async () => { - const EntryPath = Path.resolve(import.meta.dirname, '../../userscript/source/coloring-worker.ts') +async function BuildWorkerCode(): Promise { + const EntryPath = Path.resolve(import.meta.dirname, '../../userscript/source/coloring/coloring-worker.ts') const BuildResult = await ESBuild.build({ entryPoints: [EntryPath], bundle: true, @@ -44,7 +39,16 @@ test('CreateColoringWorkerPool spreads a batch across worker_threads and matches external: ['node:worker_threads'], target: ['es2024'] }) - const Code = BuildResult.outputFiles[0].text + return BuildResult.outputFiles[0].text +} + +test('coloring batch logic is deterministic when called directly (no worker)', () => { + const Items = BuildDataset() + expect(RunDirectly(Items)).toEqual(RunDirectly(Items)) +}) + +test('CreateColoringWorkerPool spreads a batch across worker_threads and matches direct-call results', async () => { + const Code = await BuildWorkerCode() const Pool = await CreateColoringWorkerPool(Code, 3) try { @@ -55,3 +59,28 @@ test('CreateColoringWorkerPool spreads a batch across worker_threads and matches Pool.Terminate() } }) + +test('RunBatch resolves to an empty array without posting to any worker', async () => { + const Pool = await CreateColoringWorkerPool(await BuildWorkerCode(), 2) + try { + expect(await Pool.RunBatch([])).toEqual([]) + } finally { + Pool.Terminate() + } +}) + +test('RunBatch rejects when a batch item makes the worker throw', async () => { + const Pool = await CreateColoringWorkerPool(await BuildWorkerCode(), 1) + try { + const BadItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '#000000', RegionPoints: [] } + await expect(Pool.RunBatch([BadItem])).rejects.toThrow('RegionPoints must contain at least one color') + } finally { + Pool.Terminate() + } +}) + +test('CreateColoringWorkerPool rejects a non-positive PoolSize', async () => { + const Code = await BuildWorkerCode() + await expect(CreateColoringWorkerPool(Code, 0)).rejects.toThrow(RangeError) + await expect(CreateColoringWorkerPool(Code, -1)).rejects.toThrow(RangeError) +}) diff --git a/testunit/tests/coloring.test.ts b/testunit/tests/coloring.test.ts index 513858a..2e224a2 100644 --- a/testunit/tests/coloring.test.ts +++ b/testunit/tests/coloring.test.ts @@ -1,6 +1,6 @@ import { test, expect } from 'vitest' import fc from 'fast-check' -import { ParseHexColor, HexDistance, HexRelativeLuminance, HexContrastRatio, IsReadableTextColor, TextReadabilityScore, IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring.js' +import { ParseHexColor, HexDistance, HexRelativeLuminance, HexContrastRatio, IsReadableTextColor, TextReadabilityScore, IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring/coloring.js' test('ParseHexColor accepts #RGB, #RRGGBB, and no-# forms', () => { expect(ParseHexColor('#fff')).toEqual([255, 255, 255]) From 34762d8a86142af1ef02199d7983f0961df45eaa Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Tue, 1 Sep 2026 05:47:30 +0000 Subject: [PATCH 14/16] Add comprehensive tests for color parsing and region membership functions - Implement tests for `ParseHexColor`, `HexDistance`, `HexRelativeLuminance`, `HexContrastRatio`, `IsReadableTextColor`, and `TextReadabilityScore` in coloring-hex.test.ts. - Validate both valid and invalid hex color formats, ensuring correct parsing and error handling. - Add tests for geometric region membership functions including `IsInsideRegion` and `RegionCentroidRatio` in coloring-region.test.ts. - Test various geometric shapes (box, line, rectangle, triangle, pyramid) for color containment. - Create a worker pool test suite in coloring-worker-pool.test.ts to validate concurrent processing of color operations. - Ensure error handling for malformed inputs and validate pool size constraints. --- testunit/tests/coloring-hex.test.ts | 265 +++++++++++++++ testunit/tests/coloring-region.test.ts | 358 ++++++++++++++++++++ testunit/tests/coloring-worker-pool.test.ts | 262 ++++++++++++++ 3 files changed, 885 insertions(+) create mode 100644 testunit/tests/coloring-hex.test.ts create mode 100644 testunit/tests/coloring-region.test.ts create mode 100644 testunit/tests/coloring-worker-pool.test.ts diff --git a/testunit/tests/coloring-hex.test.ts b/testunit/tests/coloring-hex.test.ts new file mode 100644 index 0000000..7080c7d --- /dev/null +++ b/testunit/tests/coloring-hex.test.ts @@ -0,0 +1,265 @@ +import { test, expect } from 'vitest' +import { ParseHexColor, HexDistance, HexRelativeLuminance, HexContrastRatio, IsReadableTextColor, TextReadabilityScore } from '@userscript/coloring/coloring.js' + +// Independent restatements of the WCAG formulas (not imports from coloring.ts) so these tests can catch regressions in the implementation. +function LinearizeChannelForExpectation(Channel: number): number { + const Normalized = Channel / 255 + return Normalized <= 0.04045 ? Normalized / 12.92 : ((Normalized + 0.055) / 1.055) ** 2.4 +} + +function ExpectedLuminance(R: number, G: number, B: number): number { + return 0.2126 * LinearizeChannelForExpectation(R) + 0.7152 * LinearizeChannelForExpectation(G) + 0.0722 * LinearizeChannelForExpectation(B) +} + +function ExpectedContrastRatio(TextGray: number, BackgroundGray: number): number { + const TextLuminance = ExpectedLuminance(TextGray, TextGray, TextGray) + const BackgroundLuminance = ExpectedLuminance(BackgroundGray, BackgroundGray, BackgroundGray) + const Lighter = Math.max(TextLuminance, BackgroundLuminance) + const Darker = Math.min(TextLuminance, BackgroundLuminance) + return (Lighter + 0.05) / (Darker + 0.05) +} + +function HexOfGray(Gray: number): string { + return `#${Array(3).fill(Gray.toString(16).padStart(2, '0')).join('')}` +} + +// Section A: ParseHexColor valid forms +const ValidParseHexColorCases: readonly [string, [number, number, number]][] = [ + ['#fff', [255, 255, 255]], + ['fff', [255, 255, 255]], + ['#ffffff', [255, 255, 255]], + ['ffffff', [255, 255, 255]], + ['#000', [0, 0, 0]], + ['000000', [0, 0, 0]], + ['#1a2b3c', [26, 43, 60]], + ['#1A2B3C', [26, 43, 60]], + ['#FfAaBb', [255, 170, 187]], + ['abc', [170, 187, 204]], + ['#010203', [1, 2, 3]], + ['#800000', [128, 0, 0]], + ['#008000', [0, 128, 0]], + ['#000080', [0, 0, 128]], + ['#ff0000', [255, 0, 0]], + ['#00ff00', [0, 255, 0]], + ['#0000ff', [0, 0, 255]], + ['#123', [17, 34, 51]], + ['#c0ffee', [192, 255, 238]], + ['#deadbe', [222, 173, 190]], + ['#00ffff', [0, 255, 255]], + ['#ff00ff', [255, 0, 255]], + ['#ffff00', [255, 255, 0]], + ['#ff0080', [255, 0, 128]], + ['#800080', [128, 0, 128]], + ['#7f7f7f', [127, 127, 127]], +] + +for (const [Input, Expected] of ValidParseHexColorCases) { + test(`ParseHexColor parses "${Input}" as [${Expected.join(', ')}]`, () => { + expect(ParseHexColor(Input)).toEqual(Expected) + }) +} + +// Section B: ParseHexColor invalid forms +const InvalidParseHexColorCases: readonly string[] = [ + '', '#', '#12', '#1234', '#12345', '#1234567', '12', '1234', + '#gggggg', '#zzzzzz', '#gg0000', '#12 45', '##ffffff', '#ff', 'ffffffff', '#ffffffff', +] + +for (const Input of InvalidParseHexColorCases) { + test(`ParseHexColor rejects "${Input}"`, () => { + expect(() => ParseHexColor(Input)).toThrow() + }) +} + +// Section C: HexDistance +test('HexDistance of a 16-unit difference on R alone is 16', () => { + expect(HexDistance('#100000', '#200000')).toBe(16) +}) + +test('HexDistance of a 32-unit difference on G alone is 32', () => { + expect(HexDistance('#001000', '#003000')).toBe(32) +}) + +test('HexDistance of a 64-unit difference on B alone is 64', () => { + expect(HexDistance('#000010', '#000050')).toBe(64) +}) + +test('HexDistance is zero for an identical color pair', () => { + expect(HexDistance('#123456', '#123456')).toBe(0) +}) + +test('HexDistance matches the 3-4-5 Pythagorean triple', () => { + expect(HexDistance('#000000', '#030400')).toBe(5) +}) + +test('HexDistance matches the 6-8-10 Pythagorean triple', () => { + expect(HexDistance('#000000', '#060800')).toBe(10) +}) + +test('HexDistance matches the 5-12-13 Pythagorean triple', () => { + expect(HexDistance('#000000', '#050c00')).toBe(13) +}) + +test('HexDistance matches the 2-3-6 Pythagorean triple across all three channels', () => { + expect(HexDistance('#000000', '#020306')).toBe(7) +}) + +test('HexDistance matches the 1-2-2 Pythagorean triple', () => { + expect(HexDistance('#000000', '#010202')).toBe(3) +}) + +test('HexDistance matches the 2-6-9 Pythagorean triple', () => { + expect(HexDistance('#000000', '#020609')).toBe(11) +}) + +test('HexDistance matches the 4-4-7 Pythagorean triple', () => { + expect(HexDistance('#000000', '#040407')).toBe(9) +}) + +test('HexDistance matches the 6-6-7 Pythagorean triple', () => { + expect(HexDistance('#000000', '#060607')).toBe(11) +}) + +test('HexDistance is symmetric for an arbitrary color pair', () => { + expect(HexDistance('#123456', '#abcdef')).toBe(HexDistance('#abcdef', '#123456')) +}) + +test('HexDistance is symmetric for a magenta/black pair', () => { + expect(HexDistance('#000000', '#ff00ff')).toBe(HexDistance('#ff00ff', '#000000')) +}) + +test('HexDistance of black to magenta matches the two-axis diagonal formula', () => { + expect(HexDistance('#000000', '#ff00ff')).toBeCloseTo(Math.sqrt(255 ** 2 * 2), 9) +}) + +// Section D: HexRelativeLuminance +const GrayValuesForLuminance: readonly number[] = [0, 1, 2, 10, 11, 16, 32, 64, 85, 127, 128, 160, 200, 224, 239, 240, 250, 254, 255] + +for (const Gray of GrayValuesForLuminance) { + test(`HexRelativeLuminance of gray ${Gray} matches the WCAG formula`, () => { + expect(HexRelativeLuminance(HexOfGray(Gray))).toBeCloseTo(ExpectedLuminance(Gray, Gray, Gray), 9) + }) +} + +test('HexRelativeLuminance ranks pure green above pure red at value 200', () => { + expect(HexRelativeLuminance('#00c800') > HexRelativeLuminance('#c80000')).toBe(true) +}) + +test('HexRelativeLuminance ranks pure red above pure blue at value 200', () => { + expect(HexRelativeLuminance('#c80000') > HexRelativeLuminance('#0000c8')).toBe(true) +}) + +test('HexRelativeLuminance ranks pure green above pure red at value 100', () => { + expect(HexRelativeLuminance('#006400') > HexRelativeLuminance('#640000')).toBe(true) +}) + +test('HexRelativeLuminance ranks pure red above pure blue at value 100', () => { + expect(HexRelativeLuminance('#640000') > HexRelativeLuminance('#000064')).toBe(true) +}) + +// Section E: HexContrastRatio +const GrayPairsForContrast: readonly [number, number][] = [ + [0, 255], [255, 0], [50, 200], [200, 50], [100, 100], + [10, 11], [11, 10], [0, 128], [128, 255], [64, 192], + [192, 64], [30, 220], [220, 30], [0, 0], [255, 255], + [118, 255], [119, 255], [69, 255], [255, 69], [150, 5], +] + +for (const [TextGray, BackgroundGray] of GrayPairsForContrast) { + test(`HexContrastRatio of gray ${TextGray} on gray ${BackgroundGray} matches the WCAG formula`, () => { + expect(HexContrastRatio(HexOfGray(TextGray), HexOfGray(BackgroundGray))).toBeCloseTo(ExpectedContrastRatio(TextGray, BackgroundGray), 9) + }) +} + +// Section F: IsReadableTextColor +const TextGraysForReadability: readonly number[] = [0, 32, 64, 96, 128, 160, 192, 224, 255] + +for (const TextGray of TextGraysForReadability) { + test(`IsReadableTextColor of gray ${TextGray} on white matches the default AA threshold`, () => { + expect(IsReadableTextColor(HexOfGray(TextGray), '#ffffff')).toBe(ExpectedContrastRatio(TextGray, 255) >= 4.5) + }) + + test(`IsReadableTextColor of gray ${TextGray} on black matches the default AA threshold`, () => { + expect(IsReadableTextColor(HexOfGray(TextGray), '#000000')).toBe(ExpectedContrastRatio(TextGray, 0) >= 4.5) + }) + + test(`IsReadableTextColor of gray ${TextGray} on white with LargeText matches the AA-Large threshold`, () => { + expect(IsReadableTextColor(HexOfGray(TextGray), '#ffffff', { LargeText: true })).toBe(ExpectedContrastRatio(TextGray, 255) >= 3) + }) + + test(`IsReadableTextColor of gray ${TextGray} on white with Enhanced matches the AAA threshold`, () => { + expect(IsReadableTextColor(HexOfGray(TextGray), '#ffffff', { Enhanced: true })).toBe(ExpectedContrastRatio(TextGray, 255) >= 7) + }) +} + +// Section G: TextReadabilityScore +for (const [TextGray, BackgroundGray] of GrayPairsForContrast.slice(0, 18)) { + test(`TextReadabilityScore of gray ${TextGray} on gray ${BackgroundGray} normalizes the contrast ratio`, () => { + expect(TextReadabilityScore(HexOfGray(TextGray), HexOfGray(BackgroundGray))).toBeCloseTo((ExpectedContrastRatio(TextGray, BackgroundGray) - 1) / 20, 9) + }) +} + +// Section H: malformed HEX input across all functions +test('HexDistance rejects a malformed first argument', () => { + expect(() => HexDistance('#zzzzzz', '#ffffff')).toThrow() +}) + +test('HexDistance rejects a malformed second argument', () => { + expect(() => HexDistance('#ffffff', '#zzzzzz')).toThrow() +}) + +test('HexRelativeLuminance rejects a short HEX string', () => { + expect(() => HexRelativeLuminance('#12345')).toThrow() +}) + +test('HexRelativeLuminance rejects an empty string', () => { + expect(() => HexRelativeLuminance('')).toThrow() +}) + +test('HexContrastRatio rejects a malformed first argument', () => { + expect(() => HexContrastRatio('#zzzzzz', '#ffffff')).toThrow() +}) + +test('HexContrastRatio rejects a malformed second argument', () => { + expect(() => HexContrastRatio('#ffffff', '#zzzzzz')).toThrow() +}) + +test('HexContrastRatio rejects an empty first argument', () => { + expect(() => HexContrastRatio('', '#ffffff')).toThrow() +}) + +test('IsReadableTextColor rejects a malformed first argument', () => { + expect(() => IsReadableTextColor('#zzzzzz', '#ffffff')).toThrow() +}) + +test('IsReadableTextColor rejects a malformed second argument', () => { + expect(() => IsReadableTextColor('#ffffff', '#zzzzzz')).toThrow() +}) + +test('IsReadableTextColor rejects a too-short first argument', () => { + expect(() => IsReadableTextColor('#12', '#ffffff')).toThrow() +}) + +test('TextReadabilityScore rejects a malformed first argument', () => { + expect(() => TextReadabilityScore('#zzzzzz', '#ffffff')).toThrow() +}) + +test('TextReadabilityScore rejects a malformed second argument', () => { + expect(() => TextReadabilityScore('#ffffff', '#zzzzzz')).toThrow() +}) + +test('TextReadabilityScore rejects two empty arguments', () => { + expect(() => TextReadabilityScore('', '')).toThrow() +}) + +test('HexDistance rejects two empty arguments', () => { + expect(() => HexDistance('', '')).toThrow() +}) + +test('HexRelativeLuminance rejects invalid hex characters', () => { + expect(() => HexRelativeLuminance('#gggggg')).toThrow() +}) + +test('HexContrastRatio rejects a mismatched-length second argument', () => { + expect(() => HexContrastRatio('#123456', '#12345')).toThrow() +}) diff --git a/testunit/tests/coloring-region.test.ts b/testunit/tests/coloring-region.test.ts new file mode 100644 index 0000000..a6b3750 --- /dev/null +++ b/testunit/tests/coloring-region.test.ts @@ -0,0 +1,358 @@ +import { test, expect } from 'vitest' +import { IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring/coloring.js' + +function HexOfRGB(R: number, G: number, B: number): string { + const Clamp = (Value: number) => Math.max(0, Math.min(255, Math.round(Value))) + return `#${[R, G, B].map(Value => Clamp(Value).toString(16).padStart(2, '0')).join('')}` +} + +// A cube with each axis ranging over [BoxMin, BoxMax], so membership reduces to independent per-channel interval containment. +const BoxMin = 50 +const BoxMax = 200 +const BoxCenter = 125 +const BoxHalfExtent = 75 +const Box: string[] = [ + '#323232', '#c83232', '#32c832', '#3232c8', + '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8', +] + +// A 2-point line varying only R, so membership reduces to "R within [0, 100] and G/B unchanged". +const Line: string[] = ['#000000', '#640000'] + +// A rectangle in the R/G plane with B fixed at 0, so membership reduces to independent R/G interval containment. +const Square: string[] = ['#323200', '#c83200', '#c8c800', '#32c800'] + +// A right triangle with legs along R and G, B fixed at 0, so membership reduces to R>=0 && G>=0 && R+G<=150. +const Triangle: string[] = ['#000000', '#960000', '#009600'] + +// A pyramid (square base + apex), reused from the geometry primitives covered in coloring.test.ts. +const Pyramid: string[] = ['#000000', '#c80000', '#c8c800', '#00c800', '#6464c8'] + +// Section A: axis-aligned box membership grid (below-min / at-min / at-max / above-max per channel) +const BoxAxisSamples: readonly number[] = [30, 50, 200, 220] + +for (const R of BoxAxisSamples) { + for (const G of BoxAxisSamples) { + for (const B of BoxAxisSamples) { + const Expected = R >= BoxMin && R <= BoxMax && G >= BoxMin && G <= BoxMax && B >= BoxMin && B <= BoxMax + test(`IsInsideRegion of box at R=${R} G=${G} B=${B} is ${Expected}`, () => { + expect(IsInsideRegion(HexOfRGB(R, G, B), Box)).toBe(Expected) + }) + } + } +} + +// Section B: collinear line membership +const LineOnAxisSamples: readonly number[] = [0, 10, 25, 50, 75, 90, 99, 100, 101, 120, 150, 255] + +for (const R of LineOnAxisSamples) { + const Expected = R >= 0 && R <= 100 + test(`IsInsideRegion of line at R=${R} is ${Expected}`, () => { + expect(IsInsideRegion(HexOfRGB(R, 0, 0), Line)).toBe(Expected) + }) +} + +test('IsInsideRegion of line rejects a point off the line via G', () => { + expect(IsInsideRegion(HexOfRGB(50, 5, 0), Line)).toBe(false) +}) + +test('IsInsideRegion of line rejects a point off the line via B', () => { + expect(IsInsideRegion(HexOfRGB(50, 0, 5), Line)).toBe(false) +}) + +test('IsInsideRegion of line rejects a point off the line via both G and B', () => { + expect(IsInsideRegion(HexOfRGB(50, 5, 5), Line)).toBe(false) +}) + +// Section C: rectangle (coplanar polygon) membership +for (const R of BoxAxisSamples) { + for (const G of BoxAxisSamples) { + const Expected = R >= BoxMin && R <= BoxMax && G >= BoxMin && G <= BoxMax + test(`IsInsideRegion of rectangle at R=${R} G=${G} is ${Expected}`, () => { + expect(IsInsideRegion(HexOfRGB(R, G, 0), Square)).toBe(Expected) + }) + } +} + +test('IsInsideRegion of rectangle rejects an otherwise-valid point off its plane (B=5)', () => { + expect(IsInsideRegion(HexOfRGB(100, 100, 5), Square)).toBe(false) +}) + +test('IsInsideRegion of rectangle rejects an otherwise-valid point off its plane (B=10)', () => { + expect(IsInsideRegion(HexOfRGB(150, 150, 10), Square)).toBe(false) +}) + +test('IsInsideRegion of rectangle rejects a boundary point off its plane (B=1)', () => { + expect(IsInsideRegion(HexOfRGB(50, 50, 1), Square)).toBe(false) +}) + +// Section D: right-triangle (linear inequality) membership +const TriangleSamples: readonly [number, number, boolean][] = [ + [0, 0, true], + [150, 0, true], + [0, 150, true], + [75, 75, true], + [50, 50, true], + [10, 10, true], + [140, 5, true], + [1, 1, true], + [100, 100, false], + [0, 160, false], + [80, 80, false], + [140, 15, false], + [160, 0, false], +] + +for (const [R, G, Expected] of TriangleSamples) { + test(`IsInsideRegion of triangle at R=${R} G=${G} is ${Expected}`, () => { + expect(IsInsideRegion(HexOfRGB(R, G, 0), Triangle)).toBe(Expected) + }) +} + +// Section E: pyramid (volumetric) membership via convex-combination (always inside) and bounding-box (always outside) reasoning +const PyramidVertices: readonly [number, number, number][] = [[0, 0, 0], [200, 0, 0], [200, 200, 0], [0, 200, 0], [100, 100, 200]] + +for (const [Index, Vertex] of PyramidVertices.entries()) { + test(`IsInsideRegion of pyramid accepts its own vertex #${Index}`, () => { + expect(IsInsideRegion(HexOfRGB(...Vertex), Pyramid)).toBe(true) + }) +} + +test('IsInsideRegion of pyramid accepts the average of all 5 vertices', () => { + expect(IsInsideRegion(HexOfRGB(100, 100, 40), Pyramid)).toBe(true) +}) + +test('IsInsideRegion of pyramid accepts the midpoint of the base and apex', () => { + expect(IsInsideRegion(HexOfRGB(50, 50, 100), Pyramid)).toBe(true) +}) + +test('IsInsideRegion of pyramid accepts the midpoint of two base vertices', () => { + expect(IsInsideRegion(HexOfRGB(200, 100, 0), Pyramid)).toBe(true) +}) + +test('IsInsideRegion of pyramid accepts the average of the 4 base vertices', () => { + expect(IsInsideRegion(HexOfRGB(100, 100, 0), Pyramid)).toBe(true) +}) + +test('IsInsideRegion of pyramid rejects a point with R beyond every vertex', () => { + expect(IsInsideRegion(HexOfRGB(255, 0, 0), Pyramid)).toBe(false) +}) + +test('IsInsideRegion of pyramid rejects a point with G beyond every vertex', () => { + expect(IsInsideRegion(HexOfRGB(0, 255, 0), Pyramid)).toBe(false) +}) + +test('IsInsideRegion of pyramid rejects a point with B beyond every vertex', () => { + expect(IsInsideRegion(HexOfRGB(0, 0, 255), Pyramid)).toBe(false) +}) + +test('IsInsideRegion of pyramid rejects a point beyond every vertex on all channels', () => { + expect(IsInsideRegion(HexOfRGB(255, 255, 255), Pyramid)).toBe(false) +}) + +// Section F: degenerate inputs and error cases +test('IsInsideRegion tolerates a region with a duplicated point', () => { + const BoxWithDuplicate = [...Box, Box[0]] + expect(IsInsideRegion(HexOfRGB(BoxCenter, BoxCenter, BoxCenter), BoxWithDuplicate)).toBe(true) +}) + +test('IsInsideRegion treats a single-point region as an exact-match test (matching case)', () => { + expect(IsInsideRegion('#808080', ['#808080'])).toBe(true) +}) + +test('IsInsideRegion treats a single-point region as an exact-match test (non-matching case)', () => { + expect(IsInsideRegion('#808081', ['#808080'])).toBe(false) +}) + +test('IsInsideRegion rejects an empty RegionPoints array', () => { + expect(() => IsInsideRegion('#808080', [])).toThrow(RangeError) +}) + +test('IsInsideRegion rejects a malformed color inside RegionPoints', () => { + expect(() => IsInsideRegion('#808080', ['#zzzzzz'])).toThrow() +}) + +test('IsInsideRegion rejects a malformed ComparePointHex', () => { + expect(() => IsInsideRegion('#zzzzzz', Box)).toThrow() +}) + +test('IsInsideRegion accepts a point on a 3-point collinear region', () => { + expect(IsInsideRegion('#646464', ['#000000', '#0a0a0a', '#c8c8c8'])).toBe(true) +}) + +test('IsInsideRegion rejects a point off a 3-point collinear region', () => { + expect(IsInsideRegion('#ff0000', ['#000000', '#0a0a0a', '#c8c8c8'])).toBe(false) +}) + +// Section G: exact-boundary vs just-outside membership (Epsilon vs 1-unit hex granularity) +const BoxFaceBoundaryCases: readonly [number, number, number, boolean][] = [ + [BoxMin, BoxCenter, BoxCenter, true], [BoxMin - 1, BoxCenter, BoxCenter, false], + [BoxMax, BoxCenter, BoxCenter, true], [BoxMax + 1, BoxCenter, BoxCenter, false], + [BoxCenter, BoxMin, BoxCenter, true], [BoxCenter, BoxMin - 1, BoxCenter, false], + [BoxCenter, BoxMax, BoxCenter, true], [BoxCenter, BoxMax + 1, BoxCenter, false], + [BoxCenter, BoxCenter, BoxMin, true], [BoxCenter, BoxCenter, BoxMin - 1, false], + [BoxCenter, BoxCenter, BoxMax, true], [BoxCenter, BoxCenter, BoxMax + 1, false], +] + +for (const [R, G, B, Expected] of BoxFaceBoundaryCases) { + test(`IsInsideRegion of box face boundary at R=${R} G=${G} B=${B} is ${Expected}`, () => { + expect(IsInsideRegion(HexOfRGB(R, G, B), Box)).toBe(Expected) + }) +} + +// Section H: RegionCentroidRatio closed-form check for an axis-aligned box (Ratio = 1 - Delta/HalfExtent along a single axis) +const BoxCentroidDeltas: readonly number[] = [0, 25, 50, 75, 76, 100, 125] + +for (const Delta of BoxCentroidDeltas) { + const Expected = Delta <= BoxHalfExtent ? 1 - Delta / BoxHalfExtent : -1 + + test(`RegionCentroidRatio of box at R-Delta=${Delta} is ${Expected.toFixed(4)}`, () => { + expect(RegionCentroidRatio(HexOfRGB(BoxCenter + Delta, BoxCenter, BoxCenter), Box)).toBeCloseTo(Expected, 6) + }) + + test(`RegionCentroidRatio of box at G-Delta=${Delta} is ${Expected.toFixed(4)}`, () => { + expect(RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter + Delta, BoxCenter), Box)).toBeCloseTo(Expected, 6) + }) + + test(`RegionCentroidRatio of box at B-Delta=${Delta} is ${Expected.toFixed(4)}`, () => { + expect(RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter, BoxCenter + Delta), Box)).toBeCloseTo(Expected, 6) + }) +} + +// Section I: RegionCentroidRatio for the line, rectangle, and pyramid shapes +test('RegionCentroidRatio is 1 at the line midpoint centroid', () => { + expect(RegionCentroidRatio('#320000', Line)).toBe(1) +}) + +test('RegionCentroidRatio is 0 at the line start endpoint', () => { + expect(RegionCentroidRatio('#000000', Line)).toBeCloseTo(0, 6) +}) + +test('RegionCentroidRatio is 0 at the line end endpoint', () => { + expect(RegionCentroidRatio('#640000', Line)).toBeCloseTo(0, 6) +}) + +test('RegionCentroidRatio is -1 beyond the line end', () => { + expect(RegionCentroidRatio('#960000', Line)).toBe(-1) +}) + +test('RegionCentroidRatio is -1 off the line entirely', () => { + expect(RegionCentroidRatio('#32000a', Line)).toBe(-1) +}) + +test('RegionCentroidRatio is 1 at the rectangle centroid', () => { + expect(RegionCentroidRatio('#7d7d00', Square)).toBe(1) +}) + +test('RegionCentroidRatio is 0 at a rectangle corner', () => { + expect(RegionCentroidRatio('#323200', Square)).toBeCloseTo(0, 6) +}) + +test('RegionCentroidRatio is 0 on a rectangle edge midpoint', () => { + expect(RegionCentroidRatio('#7d3200', Square)).toBeCloseTo(0, 6) +}) + +test('RegionCentroidRatio is -1 outside the rectangle', () => { + expect(RegionCentroidRatio('#000000', Square)).toBe(-1) +}) + +test('RegionCentroidRatio is -1 off the rectangle plane at its own centroid position', () => { + expect(RegionCentroidRatio('#7d7d05', Square)).toBe(-1) +}) + +test('RegionCentroidRatio is 1 at the pyramid volume centroid', () => { + expect(RegionCentroidRatio('#646432', Pyramid)).toBe(1) +}) + +test('RegionCentroidRatio is 0 at a pyramid vertex', () => { + expect(RegionCentroidRatio('#000000', Pyramid)).toBeCloseTo(0, 6) +}) + +test('RegionCentroidRatio is -1 beyond the pyramid bounding box', () => { + expect(RegionCentroidRatio('#ffffff', Pyramid)).toBe(-1) +}) + +test('RegionCentroidRatio is strictly between -1 and 1 at the pyramid base-square average (not the volume centroid)', () => { + const Ratio = RegionCentroidRatio('#646400', Pyramid) + expect(Ratio > -1 && Ratio < 1).toBe(true) +}) + +test('RegionCentroidRatio is -1 for a rectangle corner offset outside the plane', () => { + expect(RegionCentroidRatio('#c8c805', Square)).toBe(-1) +}) + +// Section J: monotonic decrease from centroid towards the boundary +test('RegionCentroidRatio decreases monotonically along the R axis (20 -> 40 -> 60)', () => { + const Near = RegionCentroidRatio(HexOfRGB(BoxCenter + 20, BoxCenter, BoxCenter), Box) + const Middle = RegionCentroidRatio(HexOfRGB(BoxCenter + 40, BoxCenter, BoxCenter), Box) + const Far = RegionCentroidRatio(HexOfRGB(BoxCenter + 60, BoxCenter, BoxCenter), Box) + expect(Near > Middle).toBe(true) + expect(Middle > Far).toBe(true) +}) + +test('RegionCentroidRatio decreases monotonically along the G axis (20 -> 40 -> 60)', () => { + const Near = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter + 20, BoxCenter), Box) + const Middle = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter + 40, BoxCenter), Box) + const Far = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter + 60, BoxCenter), Box) + expect(Near > Middle).toBe(true) + expect(Middle > Far).toBe(true) +}) + +test('RegionCentroidRatio decreases monotonically along the B axis (20 -> 40 -> 60)', () => { + const Near = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter, BoxCenter + 20), Box) + const Middle = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter, BoxCenter + 40), Box) + const Far = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter, BoxCenter + 60), Box) + expect(Near > Middle).toBe(true) + expect(Middle > Far).toBe(true) +}) + +test('RegionCentroidRatio decreases monotonically along a diagonal direction (near -> mid)', () => { + const Near = RegionCentroidRatio(HexOfRGB(BoxCenter + 20, BoxCenter + 20, BoxCenter + 20), Box) + const Mid = RegionCentroidRatio(HexOfRGB(BoxCenter + 40, BoxCenter + 40, BoxCenter + 40), Box) + expect(Near > Mid).toBe(true) +}) + +test('RegionCentroidRatio decreases monotonically along a diagonal direction (mid -> far)', () => { + const Mid = RegionCentroidRatio(HexOfRGB(BoxCenter + 40, BoxCenter + 40, BoxCenter + 40), Box) + const Far = RegionCentroidRatio(HexOfRGB(BoxCenter + 60, BoxCenter + 60, BoxCenter + 60), Box) + expect(Mid > Far).toBe(true) +}) + +// Section K: near-boundary precision and a globally-extreme (0-254) box +const NearBoundaryDeltas: readonly [number, boolean][] = [[74, true], [76, false]] + +for (const [Delta, ExpectPositive] of NearBoundaryDeltas) { + test(`RegionCentroidRatio of box at R-Delta=${Delta} is ${ExpectPositive ? 'positive' : '-1'}`, () => { + const Ratio = RegionCentroidRatio(HexOfRGB(BoxCenter + Delta, BoxCenter, BoxCenter), Box) + expect(ExpectPositive ? Ratio > 0 : Ratio === -1).toBe(true) + }) + + test(`RegionCentroidRatio of box at G-Delta=${Delta} is ${ExpectPositive ? 'positive' : '-1'}`, () => { + const Ratio = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter + Delta, BoxCenter), Box) + expect(ExpectPositive ? Ratio > 0 : Ratio === -1).toBe(true) + }) + + test(`RegionCentroidRatio of box at B-Delta=${Delta} is ${ExpectPositive ? 'positive' : '-1'}`, () => { + const Ratio = RegionCentroidRatio(HexOfRGB(BoxCenter, BoxCenter, BoxCenter + Delta), Box) + expect(ExpectPositive ? Ratio > 0 : Ratio === -1).toBe(true) + }) +} + +const FullRangeBox: string[] = [ + '#000000', '#fe0000', '#00fe00', '#0000fe', + '#fefe00', '#fe00fe', '#00fefe', '#fefefe', +] +const FullRangeCenter = 127 +const FullRangeHalfExtent = 127 + +test('RegionCentroidRatio is 0 exactly at the half-extent of a full-range (0-254) box', () => { + expect(RegionCentroidRatio(HexOfRGB(FullRangeCenter + FullRangeHalfExtent, FullRangeCenter, FullRangeCenter), FullRangeBox)).toBeCloseTo(0, 3) +}) + +test('RegionCentroidRatio is positive just inside the half-extent of a full-range (0-254) box', () => { + expect(RegionCentroidRatio(HexOfRGB(FullRangeCenter + FullRangeHalfExtent - 1, FullRangeCenter, FullRangeCenter), FullRangeBox) > 0).toBe(true) +}) + +test('RegionCentroidRatio is -1 just outside the half-extent of a full-range (0-254) box', () => { + expect(RegionCentroidRatio(HexOfRGB(FullRangeCenter + FullRangeHalfExtent + 1, FullRangeCenter, FullRangeCenter), FullRangeBox)).toBe(-1) +}) diff --git a/testunit/tests/coloring-worker-pool.test.ts b/testunit/tests/coloring-worker-pool.test.ts new file mode 100644 index 0000000..39424ee --- /dev/null +++ b/testunit/tests/coloring-worker-pool.test.ts @@ -0,0 +1,262 @@ +import { test, expect, beforeAll, afterAll } from 'vitest' +import * as Path from 'node:path' +import * as ESBuild from 'esbuild' +import { IsInsideRegion, RegionCentroidRatio } from '@userscript/coloring/coloring.js' +import { CreateColoringWorkerPool, type ColoringWorkerPool } from '@userscript/coloring/coloring-client.js' +import type { ColoringBatchItem, ColoringBatchResultValue } from '@userscript/coloring/coloring-types.js' + +const CandidateColors: readonly string[] = [ + '#000000', '#111111', '#7f7f7f', '#ffffff', '#ff8800', + '#336699', '#abcdef', '#123456', '#800000', '#00ff00', +] + +const Regions: Record = { + Grayscale: ['#000000', '#ffffff'], + Warm: ['#ff0000', '#ffff00', '#996633'], + Cube: ['#323232', '#c83232', '#32c832', '#3232c8', '#c8c832', '#c832c8', '#32c8c8', '#c8c8c8'], + Pyramid: ['#000000', '#c80000', '#c8c800', '#00c800', '#6464c8'], +} + +const Ops: readonly ColoringBatchItem['Op'][] = ['IsInsideRegion', 'RegionCentroidRatio'] + +function RunDirectly(Items: readonly ColoringBatchItem[]): ColoringBatchResultValue[] { + return Items.map(Item => Item.Op === 'IsInsideRegion' + ? IsInsideRegion(Item.ComparePointHex, Item.RegionPoints) + : RegionCentroidRatio(Item.ComparePointHex, Item.RegionPoints)) +} + +async function BuildWorkerCode(): Promise { + const EntryPath = Path.resolve(import.meta.dirname, '../../userscript/source/coloring/coloring-worker.ts') + const BuildResult = await ESBuild.build({ + entryPoints: [EntryPath], + bundle: true, + write: false, + external: ['node:worker_threads'], + target: ['es2024'], + }) + return BuildResult.outputFiles[0].text +} + +function BuildDatasetOfSize(Size: number): ColoringBatchItem[] { + const Items: ColoringBatchItem[] = [] + const RegionList = Object.values(Regions) + for (let Index = 0; Index < Size; Index++) { + const ColorHex = CandidateColors[Index % CandidateColors.length] + const RegionPoints = RegionList[Index % RegionList.length] + const Op = Ops[Index % Ops.length] + Items.push({ Op, ComparePointHex: ColorHex, RegionPoints: RegionPoints as string[] }) + } + return Items +} + +let WorkerCode: string +let SharedPool: ColoringWorkerPool + +beforeAll(async () => { + WorkerCode = await BuildWorkerCode() + SharedPool = await CreateColoringWorkerPool(WorkerCode, 4) +}) + +afterAll(() => { + SharedPool.Terminate() +}) + +// Section A: item-level regression across colors x regions x ops +for (const ColorHex of CandidateColors) { + for (const [RegionName, RegionPoints] of Object.entries(Regions)) { + for (const Op of Ops) { + test(`CreateColoringWorkerPool matches direct ${Op} for ${ColorHex} against ${RegionName}`, async () => { + const Item: ColoringBatchItem = { Op, ComparePointHex: ColorHex, RegionPoints: RegionPoints as string[] } + const [PoolResult] = await SharedPool.RunBatch([Item]) + expect(PoolResult).toEqual(RunDirectly([Item])[0]) + }) + } + } +} + +// Section B: PoolSize variants +const PoolSizeVariants: readonly number[] = [1, 2, 3, 4, 5, 8, 16] + +for (const PoolSize of PoolSizeVariants) { + test(`CreateColoringWorkerPool with PoolSize=${PoolSize} matches direct-call results`, async () => { + const Pool = await CreateColoringWorkerPool(WorkerCode, PoolSize) + try { + const Items = BuildDatasetOfSize(24) + const Results = await Pool.RunBatch(Items) + expect(Results).toEqual(RunDirectly(Items)) + } finally { + Pool.Terminate() + } + }) +} + +// Section C: order preservation across dataset sizes not evenly divisible by the pool size +const OrderPreservationSizes: readonly number[] = [1, 2, 3, 7, 13, 50] + +for (const Size of OrderPreservationSizes) { + test(`CreateColoringWorkerPool preserves item order for a dataset of size ${Size}`, async () => { + const Items = BuildDatasetOfSize(Size) + const Results = await SharedPool.RunBatch(Items) + expect(Results).toEqual(RunDirectly(Items)) + expect(Results.length).toBe(Size) + }) +} + +// Section D: concurrency - distinct simultaneous RunBatch calls must not cross-contaminate +test('CreateColoringWorkerPool keeps 3 concurrent RunBatch calls with different datasets independent', async () => { + const DatasetA = BuildDatasetOfSize(5) + const DatasetB = BuildDatasetOfSize(9) + const DatasetC = BuildDatasetOfSize(17) + + const [ResultsA, ResultsB, ResultsC] = await Promise.all([ + SharedPool.RunBatch(DatasetA), + SharedPool.RunBatch(DatasetB), + SharedPool.RunBatch(DatasetC), + ]) + + expect(ResultsA).toEqual(RunDirectly(DatasetA)) + expect(ResultsB).toEqual(RunDirectly(DatasetB)) + expect(ResultsC).toEqual(RunDirectly(DatasetC)) +}) + +test('CreateColoringWorkerPool keeps 5 concurrent same-size RunBatch calls with different data independent', async () => { + const Datasets = Array.from({ length: 5 }, (Unused, Index) => BuildDatasetOfSize(6).map(Item => ({ ...Item, ComparePointHex: CandidateColors[(Index + Item.RegionPoints.length) % CandidateColors.length] }))) + + const AllResults = await Promise.all(Datasets.map(Dataset => SharedPool.RunBatch(Dataset))) + + for (const [Index, Results] of AllResults.entries()) expect(Results).toEqual(RunDirectly(Datasets[Index])) +}) + +test('CreateColoringWorkerPool interleaves a large and a tiny concurrent RunBatch call correctly', async () => { + const LargeDataset = BuildDatasetOfSize(40) + const TinyDataset = BuildDatasetOfSize(1) + + const [LargeResults, TinyResults] = await Promise.all([ + SharedPool.RunBatch(LargeDataset), + SharedPool.RunBatch(TinyDataset), + ]) + + expect(LargeResults).toEqual(RunDirectly(LargeDataset)) + expect(TinyResults).toEqual(RunDirectly(TinyDataset)) +}) + +// Section E: error handling for malformed batch items +test('RunBatch rejects an item with empty RegionPoints', async () => { + const BadItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '#000000', RegionPoints: [] } + await expect(SharedPool.RunBatch([BadItem])).rejects.toThrow('RegionPoints must contain at least one color') +}) + +test('RunBatch rejects a RegionCentroidRatio item with empty RegionPoints', async () => { + const BadItem: ColoringBatchItem = { Op: 'RegionCentroidRatio', ComparePointHex: '#000000', RegionPoints: [] } + await expect(SharedPool.RunBatch([BadItem])).rejects.toThrow('RegionPoints must contain at least one color') +}) + +test('RunBatch rejects an item with a malformed ComparePointHex', async () => { + const BadItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '#zzzzzz', RegionPoints: ['#000000'] } + await expect(SharedPool.RunBatch([BadItem])).rejects.toThrow() +}) + +test('RunBatch rejects an item with a malformed color inside RegionPoints', async () => { + const BadItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '#000000', RegionPoints: ['#zzzzzz'] } + await expect(SharedPool.RunBatch([BadItem])).rejects.toThrow() +}) + +test('RunBatch rejects a batch mixing a valid item with an error-causing item', async () => { + const GoodItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '#000000', RegionPoints: ['#000000', '#ffffff'] } + const BadItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '#000000', RegionPoints: [] } + await expect(SharedPool.RunBatch([GoodItem, BadItem])).rejects.toThrow('RegionPoints must contain at least one color') +}) + +test('RunBatch rejects a too-short ComparePointHex', async () => { + const BadItem: ColoringBatchItem = { Op: 'RegionCentroidRatio', ComparePointHex: '#12', RegionPoints: ['#000000'] } + await expect(SharedPool.RunBatch([BadItem])).rejects.toThrow() +}) + +test('RunBatch rejects an empty ComparePointHex', async () => { + const BadItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '', RegionPoints: ['#000000'] } + await expect(SharedPool.RunBatch([BadItem])).rejects.toThrow() +}) + +test('RunBatch on a fresh single-worker pool rejects the same way as the shared pool', async () => { + const Pool = await CreateColoringWorkerPool(WorkerCode, 1) + try { + const BadItem: ColoringBatchItem = { Op: 'IsInsideRegion', ComparePointHex: '#000000', RegionPoints: [] } + await expect(Pool.RunBatch([BadItem])).rejects.toThrow('RegionPoints must contain at least one color') + } finally { + Pool.Terminate() + } +}) + +// Section F: PoolSize validation +test('CreateColoringWorkerPool rejects PoolSize=0', async () => { + await expect(CreateColoringWorkerPool(WorkerCode, 0)).rejects.toThrow(RangeError) +}) + +test('CreateColoringWorkerPool rejects PoolSize=-1', async () => { + await expect(CreateColoringWorkerPool(WorkerCode, -1)).rejects.toThrow(RangeError) +}) + +test('CreateColoringWorkerPool rejects PoolSize=-100', async () => { + await expect(CreateColoringWorkerPool(WorkerCode, -100)).rejects.toThrow(RangeError) +}) + +test('CreateColoringWorkerPool rejects a fractional PoolSize below 1', async () => { + await expect(CreateColoringWorkerPool(WorkerCode, 0.5)).rejects.toThrow(RangeError) +}) + +// Section G: misc +test('RunBatch resolves to an empty array for an empty dataset', async () => { + expect(await SharedPool.RunBatch([])).toEqual([]) +}) + +test('RunBatch returns exactly one result per submitted item for a 50-item batch', async () => { + const Items = BuildDatasetOfSize(50) + const Results = await SharedPool.RunBatch(Items) + expect(Results.length).toBe(50) +}) + +// Section H: boundary between PoolSize and item count +test('RunBatch with item count exactly equal to PoolSize gives each worker exactly one item', async () => { + const Pool = await CreateColoringWorkerPool(WorkerCode, 4) + try { + const Items = BuildDatasetOfSize(4) + expect(await Pool.RunBatch(Items)).toEqual(RunDirectly(Items)) + } finally { + Pool.Terminate() + } +}) + +test('RunBatch with item count one less than PoolSize leaves the last worker idle', async () => { + const Pool = await CreateColoringWorkerPool(WorkerCode, 4) + try { + const Items = BuildDatasetOfSize(3) + expect(await Pool.RunBatch(Items)).toEqual(RunDirectly(Items)) + } finally { + Pool.Terminate() + } +}) + +test('RunBatch with PoolSize=1 and many items routes everything through the single worker', async () => { + const Pool = await CreateColoringWorkerPool(WorkerCode, 1) + try { + const Items = BuildDatasetOfSize(20) + expect(await Pool.RunBatch(Items)).toEqual(RunDirectly(Items)) + } finally { + Pool.Terminate() + } +}) + +test('RunBatch with item count an exact multiple of PoolSize splits evenly', async () => { + const Items = BuildDatasetOfSize(12) + expect(await SharedPool.RunBatch(Items)).toEqual(RunDirectly(Items)) +}) + +test('RunBatch with item count one more than an exact multiple of PoolSize still preserves order', async () => { + const Items = BuildDatasetOfSize(13) + expect(await SharedPool.RunBatch(Items)).toEqual(RunDirectly(Items)) +}) + +test('RunBatch with a single item on the shared 4-worker pool still resolves correctly', async () => { + const Items = BuildDatasetOfSize(1) + expect(await SharedPool.RunBatch(Items)).toEqual(RunDirectly(Items)) +}) From 7a974d2786caceaad22fc3c97db9b64c374d322c Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Tue, 1 Sep 2026 07:10:30 +0000 Subject: [PATCH 15/16] feat: add utility functions for image container validation and create element helper --- testunit/tests/pl-utils.test.ts | 88 +++++++++++++++++++++++++++++++++ userscript/source/PL/utils.ts | 31 ++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 testunit/tests/pl-utils.test.ts create mode 100644 userscript/source/PL/utils.ts diff --git a/testunit/tests/pl-utils.test.ts b/testunit/tests/pl-utils.test.ts new file mode 100644 index 0000000..d7d0590 --- /dev/null +++ b/testunit/tests/pl-utils.test.ts @@ -0,0 +1,88 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { IsEffectiveImageContainer } from '@userscript/PL/utils.js' + +type TestElementOptions = { + Attributes?: Record + Display?: string + Height?: number + Images?: HTMLElement[] + Sources?: HTMLElement[] + Width?: number +} + +function CreateElement(TagName: string, Options: TestElementOptions = {}): HTMLElement { + const { + Attributes = {}, + Display = 'block', + Height = 100, + Images = [], + Sources = [], + Width = 100, + } = Options + + return { + tagName: TagName.toUpperCase(), + getAttribute(Name: string): string | null { + return Attributes[Name] ?? null + }, + getBoundingClientRect(): DOMRect { + return { width: Width, height: Height } as DOMRect + }, + querySelectorAll(Selector: string): NodeListOf { + const Elements = Selector === 'source' ? Sources : Selector === 'img' ? Images : [] + return Elements as unknown as NodeListOf + }, + dataset: { Display }, + } as unknown as HTMLElement +} + +afterEach(() => vi.unstubAllGlobals()) + +test('accepts displayed img elements with a source', () => { + vi.stubGlobal('getComputedStyle', (HTMLElem: HTMLElement) => ({ + getPropertyValue: () => HTMLElem.dataset.Display ?? 'block', + })) + + expect(IsEffectiveImageContainer(CreateElement('img', { Attributes: { src: 'image.png' } }))).toBe(true) +}) + +test('rejects imgs without a usable source or visible layout', () => { + vi.stubGlobal('getComputedStyle', (HTMLElem: HTMLElement) => ({ + getPropertyValue: () => HTMLElem.dataset.Display ?? 'block', + })) + + expect(IsEffectiveImageContainer(CreateElement('img'))).toBe(false) + expect(IsEffectiveImageContainer(CreateElement('img', { Attributes: { src: ' ' } }))).toBe(false) + expect(IsEffectiveImageContainer(CreateElement('img', { Attributes: { src: 'image.png' }, Width: 0 }))).toBe(false) + expect(IsEffectiveImageContainer(CreateElement('img', { Attributes: { src: 'image.png' }, Display: 'none' }))).toBe(false) +}) + +test('accepts displayed pictures with source candidates and fallback images', () => { + vi.stubGlobal('getComputedStyle', (HTMLElem: HTMLElement) => ({ + getPropertyValue: () => HTMLElem.dataset.Display ?? 'block', + })) + + const Source = CreateElement('source', { Attributes: { srcset: 'image.webp 1x' } }) + const Image = CreateElement('img', { Attributes: { src: 'image.png' } }) + const Picture = CreateElement('picture', { Sources: [Source], Images: [Image] }) + + expect(IsEffectiveImageContainer(Picture)).toBe(true) +}) + +test('rejects incomplete pictures and non-image containers', () => { + vi.stubGlobal('getComputedStyle', (HTMLElem: HTMLElement) => ({ + getPropertyValue: () => HTMLElem.dataset.Display ?? 'block', + })) + + const Source = CreateElement('source', { Attributes: { srcset: 'image.webp 1x' } }) + const Image = CreateElement('img', { Attributes: { src: 'image.png' } }) + + expect(IsEffectiveImageContainer(CreateElement('picture', { Images: [Image] }))).toBe(false) + expect(IsEffectiveImageContainer(CreateElement('picture', { Sources: [Source] }))).toBe(false) + expect(IsEffectiveImageContainer(CreateElement('picture', { + Sources: [CreateElement('source', { Attributes: { srcset: ' ' } })], + Images: [Image], + }))).toBe(false) + expect(IsEffectiveImageContainer(CreateElement('picture', { Sources: [Source], Images: [Image], Display: 'none' }))).toBe(false) + expect(IsEffectiveImageContainer(CreateElement('div', { Images: [Image] }))).toBe(false) +}) \ No newline at end of file diff --git a/userscript/source/PL/utils.ts b/userscript/source/PL/utils.ts new file mode 100644 index 0000000..70dc799 --- /dev/null +++ b/userscript/source/PL/utils.ts @@ -0,0 +1,31 @@ +function IsDisplayedElement(HTMLElem: HTMLElement): boolean { + if (!HTMLElem) return false + + const BoundingClientRect = HTMLElem.getBoundingClientRect() + if (BoundingClientRect.width === 0 || BoundingClientRect.height === 0) return false + + const ComputedStyle = getComputedStyle(HTMLElem) + if (ComputedStyle.getPropertyValue('display') === 'none') return false + + return true +} + +function IsEffectiveHTMLPicture(HTMLElem: HTMLElement): boolean { + if (HTMLElem.tagName.toLowerCase() !== 'picture') return false + if (!IsDisplayedElement(HTMLElem)) return false + + const ChildHTMLSourceElements = [...HTMLElem.querySelectorAll('source')] + const ChildHTMLImgElements = [...HTMLElem.querySelectorAll('img')] + + return ChildHTMLSourceElements.some((ChildHTMLSourceElement) => Boolean(ChildHTMLSourceElement.getAttribute('srcset')?.trim())) + && ChildHTMLImgElements.some((ChildHTMLImgElement) => Boolean(ChildHTMLImgElement.getAttribute('src')?.trim())) +} + +export function IsEffectiveImageContainer(HTMLElem: HTMLElement): boolean { + if (!HTMLElem) return false + if (HTMLElem.tagName.toLowerCase() === 'picture') return IsEffectiveHTMLPicture(HTMLElem) + if (HTMLElem.tagName.toLowerCase() !== 'img') return false + + return IsDisplayedElement(HTMLElem) && Boolean(HTMLElem.getAttribute('src')?.trim()) + +} \ No newline at end of file From f7de3827461794defbdd8963124b8bc2cb07f063 Mon Sep 17 00:00:00 2001 From: piquark6046 Date: Thu, 3 Sep 2026 06:15:40 +0000 Subject: [PATCH 16/16] fix: ensure consistent formatting in `ComputeFaces3D` function --- userscript/source/coloring/coloring.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/userscript/source/coloring/coloring.ts b/userscript/source/coloring/coloring.ts index 5c49524..afb7ca7 100644 --- a/userscript/source/coloring/coloring.ts +++ b/userscript/source/coloring/coloring.ts @@ -189,9 +189,9 @@ function ComputeFaces3D(Points: RGB[]): Face[] { if (!UniquePoints.every(Point => Dot(Normal, Point) <= OutwardOffset + Epsilon)) continue const Vertices = UniquePoints.filter(Point => Math.abs(Dot(Normal, Point) - OutwardOffset) <= Epsilon) - const FaceKey = Vertices.map(PointKey).toSorted().join('|') - if (FaceKeys.has(FaceKey)) continue - FaceKeys.add(FaceKey) + const FaceKey = Vertices.map(PointKey).toSorted().join('|') + if (FaceKeys.has(FaceKey)) continue + FaceKeys.add(FaceKey) const FaceCenter = PointAverage(Vertices) const AxisA = Normalize(Subtract(Vertices[0], FaceCenter))