From 70976ff03af4fe121201842e06cf0619f3ccad10 Mon Sep 17 00:00:00 2001 From: mattuy Date: Sat, 1 Aug 2026 11:53:13 +0800 Subject: [PATCH] recreate project --- .dockerignore | 5 + .gitignore | 139 ++++++++++ Dockerfile | 16 ++ LICENSE | 18 ++ README.md | 29 +++ index.html | 26 ++ nginx.conf | 28 ++ package-lock.json | 12 + package.json | 11 + scripts/build.ts | 27 ++ scripts/dev.ts | 11 + src/assets.d.ts | 1 + src/main.ts | 287 ++++++++++++++++++++ src/mosaic.test.ts | 147 +++++++++++ src/mosaic.ts | 556 +++++++++++++++++++++++++++++++++++++++ src/style.css | 637 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 20 ++ 17 files changed, 1970 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build.ts create mode 100644 scripts/dev.ts create mode 100644 src/assets.d.ts create mode 100644 src/main.ts create mode 100644 src/mosaic.test.ts create mode 100644 src/mosaic.ts create mode 100644 src/style.css create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..19fb0fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.worktrees +dist +node_modules +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b2ccbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +# ---> Node +.worktrees/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..279fae9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:1.3.11-alpine AS build + +WORKDIR /app + +COPY package.json index.html tsconfig.json ./ +COPY scripts ./scripts +COPY src ./src + +RUN bun run build + +FROM nginxinc/nginx-unprivileged:1.29.4-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 8080 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65c097e --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 mattuy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1614ff5 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# 文字马赛克 + +将中文文本转化为保留少量关键词的乱码缺损艺术。所有处理均在浏览器本地完成。 + +## 特性 + +- 使用浏览器原生中文分词自动挑选保留词 +- 支持手动切换每个候选词的显影状态 +- 标点和未保留文本会被完全替换 +- 混合低笔画少用汉字、故障符号、异文字母和编码残片 +- 总体长度接近原文,同时允许局部片段随机伸缩 +- 同一组参数可反复生成不同结果并复制为纯文本 + +## 本地开发 + +```bash +bun run dev +``` + +## 验证 + +```bash +bun test +bun run build +``` + +## 许可证 + +[MIT](./LICENSE) diff --git a/index.html b/index.html new file mode 100644 index 0000000..480d9ea --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + + 文字马赛克 + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..59b4d34 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 8080; + server_name _; + absolute_redirect off; + port_in_redirect off; + + root /usr/share/nginx/html; + index index.html; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /index.html { + add_header Cache-Control "no-cache, must-revalidate"; + } + + location ~* \.(?:css|js)$ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9b1f8c1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "text-mosaic", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "text-mosaic", + "version": "0.1.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5b8450a --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "text-mosaic", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bun --hot scripts/dev.ts", + "build": "bun scripts/build.ts", + "test": "bun test" + } +} diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..5a06ea1 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,27 @@ +import { rm } from 'node:fs/promises' +import { resolve, sep } from 'node:path' + +const projectRoot = resolve(import.meta.dir, '..') +const outputDirectory = resolve(projectRoot, 'dist') + +if (!outputDirectory.startsWith(`${projectRoot}${sep}`)) { + throw new Error(`拒绝清理项目之外的输出目录:${outputDirectory}`) +} + +await rm(outputDirectory, { recursive: true, force: true }) + +const result = await Bun.build({ + entrypoints: [resolve(projectRoot, 'index.html')], + outdir: outputDirectory, + target: 'browser', +}) + +if (!result.success) { + for (const log of result.logs) { + console.error(log) + } + + process.exit(1) +} + +console.log(`已生成 ${result.outputs.length} 个文件到 dist/`) diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..a53f098 --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,11 @@ +import homepage from '../index.html' + +const server = Bun.serve({ + port: Number(Bun.env.PORT ?? 5173), + routes: { + '/': homepage, + }, + development: true, +}) + +console.log(`文字马赛克开发服务器:http://localhost:${server.port}`) diff --git a/src/assets.d.ts b/src/assets.d.ts new file mode 100644 index 0000000..5894ae0 --- /dev/null +++ b/src/assets.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..22e1b47 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,287 @@ +import './style.css' +import { + generateMosaic, + selectKeywords, + type KeywordCandidate, +} from './mosaic' + +const SAMPLE_TEXT = + '我一直以为记忆会忠实地保存一切,后来才发现,每一次回想都在重新编造过去。' + +const app = document.querySelector('#app') + +if (!app) { + throw new Error('找不到应用挂载节点 #app') +} + +app.innerHTML = ` +
+
+ +
+

文字马赛克

+

让文字损坏,只留下几处可辨认的痕迹。

+
+ +
+ +
+
+
+
+
+ 01 +

原始文本

+
+ 0 字 +
+ + +
+ +
+
+
+ 02 +

损坏结果

+
+ +
+
+
+ + +
+
+
+ +
+
+ CONTROL SIGNAL +

侵蚀参数

+
+ +
+ + + + + +
+
+ +
+
+
+ 03 +

保留词

+

点按词语切换显影状态,原始顺序不会改变。

+
+ +
+
+
+
+ +
+ LOCAL PROCESSING +

文本只在当前浏览器中处理,不会上传。

+
+
+ +
+` + +const sourceInput = getElement('source-text') +const inputCount = getElement('input-count') +const outputRatio = getElement('output-ratio') +const mosaicOutput = getElement('mosaic-output') +const keywordList = getElement('keyword-list') +const toast = getElement('toast') +const retentionControl = getElement('retention-control') +const volumeControl = getElement('volume-control') +const disorderControl = getElement('disorder-control') +const retentionValue = getElement('retention-value') +const volumeValue = getElement('volume-value') +const disorderValue = getElement('disorder-value') +const rerollButton = getElement('reroll-button') +const copyButton = getElement('copy-button') +const reselectButton = getElement('reselect-button') + +let candidates: KeywordCandidate[] = [] +let selectionSeed = 5 +let noiseSeed = Date.now() +let toastTimer = 0 + +function getElement(id: string): T { + const element = document.getElementById(id) + + if (!element) { + throw new Error(`找不到页面元素 #${id}`) + } + + return element as T +} + +function showToast(message: string): void { + window.clearTimeout(toastTimer) + toast.textContent = message + toast.classList.add('toast--visible') + toastTimer = window.setTimeout(() => { + toast.classList.remove('toast--visible') + }, 1800) +} + +function getRetention(): number { + return Number(retentionControl.value) +} + +function renderKeywords(): void { + keywordList.replaceChildren() + + if (candidates.length === 0) { + const empty = document.createElement('p') + empty.className = 'keyword-list__empty' + empty.textContent = sourceInput.value.trim() + ? '没有识别到可保留的词语,可以尝试输入更长的中文文本。' + : '输入文字后,这里会出现可保留的词语。' + keywordList.append(empty) + return + } + + for (const candidate of candidates) { + const button = document.createElement('button') + button.type = 'button' + button.className = 'keyword-token' + button.textContent = candidate.text + button.setAttribute('aria-pressed', String(candidate.selected)) + + if (candidate.selected) { + button.classList.add('keyword-token--selected') + } + + button.addEventListener('click', () => { + candidate.selected = !candidate.selected + renderKeywords() + renderOutput() + }) + keywordList.append(button) + } +} + +function refreshCandidates(): void { + candidates = selectKeywords(sourceInput.value, getRetention(), selectionSeed) + renderKeywords() + renderOutput() +} + +function renderOutput(): void { + const result = generateMosaic(sourceInput.value, candidates, { + volume: Number(volumeControl.value), + disorder: Number(disorderControl.value), + seed: noiseSeed, + }) + + mosaicOutput.textContent = result.output || '等待文字进入损坏区' + mosaicOutput.classList.toggle('mosaic-output--empty', result.output === '') + inputCount.textContent = `${result.inputLength} 字` + outputRatio.textContent = + result.inputLength === 0 + ? '—' + : `${result.outputLength} 字 / ${Math.round((result.outputLength / result.inputLength) * 100)}%` +} + +function syncControls(): void { + retentionValue.value = `${retentionControl.value}%` + volumeValue.value = `${volumeControl.value}%` + disorderValue.value = `${disorderControl.value}%` +} + +sourceInput.addEventListener('input', () => { + selectionSeed += 1 + noiseSeed += 1 + refreshCandidates() +}) + +retentionControl.addEventListener('input', () => { + syncControls() + refreshCandidates() +}) + +volumeControl.addEventListener('input', () => { + syncControls() + renderOutput() +}) + +disorderControl.addEventListener('input', () => { + syncControls() + renderOutput() +}) + +rerollButton.addEventListener('click', () => { + noiseSeed += 1 + renderOutput() +}) + +reselectButton.addEventListener('click', () => { + selectionSeed += 1 + refreshCandidates() +}) + +copyButton.addEventListener('click', async () => { + const text = mosaicOutput.textContent ?? '' + + if (!sourceInput.value || !text) { + showToast('还没有可以复制的结果') + return + } + + try { + await navigator.clipboard.writeText(text) + showToast('已复制纯文本') + } catch (error) { + console.error('复制文字马赛克失败', error) + showToast('复制失败,请手动选择文本') + } +}) + +sourceInput.value = SAMPLE_TEXT +syncControls() +refreshCandidates() diff --git a/src/mosaic.test.ts b/src/mosaic.test.ts new file mode 100644 index 0000000..a4fb6ab --- /dev/null +++ b/src/mosaic.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'bun:test' +import { + generateMosaic, + selectKeywords, + type KeywordCandidate, +} from './mosaic' + +const SOURCE = + '我一直以为记忆会忠实地保存一切,后来才发现,每一次回想都在重新编造过去。' + +function retain( + candidates: KeywordCandidate[], + words: string[], +): KeywordCandidate[] { + return candidates.map((candidate) => ({ + ...candidate, + selected: words.includes(candidate.text), + })) +} + +describe('selectKeywords', () => { + it('优先提供中文实词候选并排除常见停用词', () => { + const candidates = selectKeywords(SOURCE, 14, 1) + const words = candidates.map((candidate) => candidate.text) + + expect(words).toContain('记忆') + expect(words).toContain('保存') + expect(words).toContain('回想') + expect(words).toContain('编造') + expect(words).not.toContain('一直') + expect(words).not.toContain('后来') + }) + + it('在有候选词时至少自动保留一个', () => { + const candidates = selectKeywords('风穿过废弃车站', 5, 1) + + expect(candidates.some((candidate) => candidate.selected)).toBe(true) + }) + + it('显影 0% 不保留词语,100% 保留全部候选词', () => { + const hidden = selectKeywords(SOURCE, 0, 1) + const revealed = selectKeywords(SOURCE, 100, 1) + + expect(hidden.some((candidate) => candidate.selected)).toBe(false) + expect(revealed.every((candidate) => candidate.selected)).toBe(true) + }) +}) + +describe('generateMosaic', () => { + it('保留指定词语并彻底替换原标点', () => { + const candidates = retain(selectKeywords(SOURCE, 14, 1), [ + '记忆', + '保存', + '回想', + '编造', + '过去', + ]) + const result = generateMosaic(SOURCE, candidates, { + volume: 100, + disorder: 58, + seed: 42, + }) + + expect(result.output).toContain('记忆') + expect(result.output).toContain('保存') + expect(result.output).toContain('回想') + expect(result.output).toContain('编造') + expect(result.output).toContain('过去') + expect(result.output).not.toMatch(/[,。!?;:“”‘’()【】、]/u) + }) + + it('将总可见长度维持在设定体量附近', () => { + const candidates = selectKeywords(SOURCE, 14, 1) + const result = generateMosaic(SOURCE, candidates, { + volume: 100, + disorder: 58, + seed: 42, + }) + const ratio = result.outputLength / result.inputLength + + expect(ratio).toBeGreaterThanOrEqual(0.97) + expect(ratio).toBeLessThanOrEqual(1.03) + }) + + it('支持将体量压缩到 10% 或膨胀到 1000%', () => { + const compressed = generateMosaic(SOURCE, [], { + volume: 10, + disorder: 0, + seed: 42, + }) + const expanded = generateMosaic(SOURCE, [], { + volume: 1000, + disorder: 0, + seed: 42, + }) + + expect(compressed.outputLength).toBe(Math.round(compressed.inputLength * 0.1)) + expect(expanded.outputLength).toBe(expanded.inputLength * 10) + }) + + it('不生成可能跳出正常行框的 Unicode 组合符', () => { + const outputs = Array.from({ length: 20 }, (_, seed) => + generateMosaic(SOURCE, [], { + volume: 1000, + disorder: 100, + seed, + }).output, + ).join('') + + expect(outputs).not.toMatch(/\p{Mark}/u) + }) + + it('相同种子可复现,不同种子会重新侵蚀', () => { + const candidates = selectKeywords(SOURCE, 14, 1) + const first = generateMosaic(SOURCE, candidates, { + volume: 100, + disorder: 58, + seed: 42, + }) + const repeated = generateMosaic(SOURCE, candidates, { + volume: 100, + disorder: 58, + seed: 42, + }) + const rerolled = generateMosaic(SOURCE, candidates, { + volume: 100, + disorder: 58, + seed: 43, + }) + + expect(repeated.output).toBe(first.output) + expect(rerolled.output).not.toBe(first.output) + }) + + it('保留换行但替换每一行中的隐藏内容', () => { + const source = '记忆,在这里。\n过去,在那里。' + const candidates = retain(selectKeywords(source, 20, 1), ['记忆', '过去']) + const result = generateMosaic(source, candidates, { + volume: 100, + disorder: 30, + seed: 7, + }) + + expect(result.output.split('\n')).toHaveLength(2) + expect(result.output).not.toMatch(/[,。]/u) + }) +}) diff --git a/src/mosaic.ts b/src/mosaic.ts new file mode 100644 index 0000000..6ceb301 --- /dev/null +++ b/src/mosaic.ts @@ -0,0 +1,556 @@ +export interface KeywordCandidate { + id: string + start: number + end: number + text: string + score: number + selected: boolean +} + +export interface MosaicOptions { + volume: number + disorder: number + seed: number +} + +export interface MosaicResult { + output: string + inputLength: number + outputLength: number + retainedLength: number +} + +interface OutputPart { + type: 'hidden' | 'retained' | 'newline' + text: string + originalLength: number + noiseLength: number + weight: number +} + +type Random = () => number + +const STOP_WORDS = new Set([ + '一个', + '一些', + '一种', + '不是', + '不能', + '不过', + '为了', + '之后', + '之前', + '于是', + '他们', + '但是', + '以及', + '仍然', + '什么', + '从而', + '以后', + '已经', + '以为', + '每一', + '你们', + '使得', + '关于', + '其实', + '再也', + '只是', + '可以', + '可能', + '后来', + '因为', + '如果', + '它们', + '对于', + '就是', + '并且', + '开始', + '我们', + '所以', + '所有', + '时候', + '是否', + '然后', + '现在', + '由于', + '直到', + '自己', + '都在', + '虽然', + '这个', + '这些', + '这种', + '那个', + '那些', + '那么', + '里面', + '重新', + '非常', + '没有', + '正在', + '能够', + '还是', + '进行', + '通过', + '需要', + '一起', + '一样', + '一般', + '一点', + '一次', + '一直', + '一切', + '不会', + '不要', + '不再', + '不只', + '不但', + '不管', + '不论', + '而且', + '或者', + '其中', + '其他', + '某个', + '某些', + '这里', + '那里', + '怎样', + '如何', + '为何', + '何时', + '我', + '你', + '他', + '她', + '它', + '这', + '那', + '是', + '的', + '地', + '得', + '了', + '着', + '过', + '在', + '和', + '与', + '及', + '而', + '也', + '都', + '就', + '又', + '还', + '才', + '次', + '被', + '把', + '将', + '会', + '能', + '可', + '不', + '没', + '很', + '更', + '最', + '让', + '从', + '到', + '对', + '于', + '以', + '为', + '所', + '其', +]) + +const SIMPLE_HAN = [ + '丌', + '乂', + '乜', + '亍', + '兀', + '仝', + '冇', + '夬', + '尢', + '孑', + '孓', + '廿', + '卅', + '卌', + '凼', + '彳', + '氕', + '氘', + '叵', + '玊', + '旡', + '乇', + '亓', + '伋', + '佀', + '佧', + '侘', +] as const + +const GLITCH_SYMBOLS = [ + '▒', + '▓', + '░', + '╳', + '⍉', + '⌁', + '⊘', + '⊗', + '⊙', + '∷', + '※', + '⌗', + '⎔', + '␀', + '␟', + '�', +] as const + +const FOREIGN_GLYPHS = [ + 'Ж', + 'Д', + 'æ', + 'Ø', + 'λ', + 'ϟ', + '҂', + 'ヰ', + 'ヱ', + 'ヲ', + 'ヵ', + 'メ', +] as const + +const ENCODING_FRAGMENTS = ['Ã', 'Â', 'æ', 'å', '¤', '锟', '斤', '拷'] as const + +const COMPLEX_HAN = ['黻', '鬯', '夔', '爨', '龘', '靐', '罅', '黹'] as const + +const HAN_PATTERN = /\p{Script=Han}/u +const WORD_PATTERN = /[\p{L}\p{N}]/u + +const wordSegmenter = new Intl.Segmenter('zh-CN', { granularity: 'word' }) +const graphemeSegmenter = new Intl.Segmenter('zh-CN', { + granularity: 'grapheme', +}) + +function createRandom(seed: number): Random { + let state = seed >>> 0 + + return () => { + state += 0x6d2b79f5 + let value = state + value = Math.imul(value ^ (value >>> 15), value | 1) + value ^= value + Math.imul(value ^ (value >>> 7), value | 61) + return ((value ^ (value >>> 14)) >>> 0) / 4294967296 + } +} + +function hashText(value: string): number { + let hash = 2166136261 + + for (const character of value) { + hash ^= character.codePointAt(0) ?? 0 + hash = Math.imul(hash, 16777619) + } + + return hash >>> 0 +} + +function countGraphemes(value: string): number { + let count = 0 + + for (const segment of graphemeSegmenter.segment(value)) { + if (segment.segment !== '\n' && segment.segment !== '\r') { + count += 1 + } + } + + return count +} + +function isKeywordCandidate(segment: string, isWordLike: boolean): boolean { + if (!isWordLike || STOP_WORDS.has(segment) || !WORD_PATTERN.test(segment)) { + return false + } + + const length = countGraphemes(segment) + + if (HAN_PATTERN.test(segment)) { + return length >= 1 && length <= 8 + } + + return length >= 2 && length <= 16 +} + +function scoreCandidate( + text: string, + start: number, + frequency: number, + selectionSeed: number, +): number { + const length = countGraphemes(text) + const lengthScore = + length === 2 ? 2.8 : length === 3 ? 2.4 : length === 4 ? 1.8 : 0.7 + const frequencyScore = Math.min(frequency - 1, 3) * 1.2 + const hanScore = HAN_PATTERN.test(text) ? 0.8 : 0.3 + const random = createRandom(hashText(`${text}:${start}:${selectionSeed}`))() + + return lengthScore + frequencyScore + hanScore + random * 1.4 +} + +export function selectKeywords( + text: string, + retention: number, + selectionSeed = 1, +): KeywordCandidate[] { + const segments = Array.from(wordSegmenter.segment(text)) + const candidateSegments = segments.filter((segment) => + isKeywordCandidate(segment.segment, Boolean(segment.isWordLike)), + ) + const frequencies = new Map() + + for (const segment of candidateSegments) { + frequencies.set(segment.segment, (frequencies.get(segment.segment) ?? 0) + 1) + } + + const candidates = candidateSegments.map((segment) => ({ + id: `${segment.index}:${segment.index + segment.segment.length}`, + start: segment.index, + end: segment.index + segment.segment.length, + text: segment.segment, + score: scoreCandidate( + segment.segment, + segment.index, + frequencies.get(segment.segment) ?? 1, + selectionSeed, + ), + selected: false, + })) + + if (candidates.length === 0) { + return candidates + } + + const desiredCount = + retention <= 0 + ? 0 + : Math.min( + candidates.length, + Math.max(1, Math.ceil(candidates.length * (retention / 100))), + ) + const ranked = [...candidates].sort( + (left, right) => right.score - left.score || left.start - right.start, + ) + const selectedIds = new Set( + ranked.slice(0, desiredCount).map((candidate) => candidate.id), + ) + + return candidates.map((candidate) => ({ + ...candidate, + selected: selectedIds.has(candidate.id), + })) +} + +function choose(values: readonly T[], random: Random): T { + return values[Math.floor(random() * values.length)] +} + +function chooseNoiseGlyph(random: Random): string { + const categoryRoll = random() * 100 + + if (categoryRoll < 60) { + return choose(SIMPLE_HAN, random) + } + + if (categoryRoll < 80) { + return choose(GLITCH_SYMBOLS, random) + } + + if (categoryRoll < 90) { + return choose(FOREIGN_GLYPHS, random) + } + + if (categoryRoll < 97) { + return choose(ENCODING_FRAGMENTS, random) + } + + return choose(COMPLEX_HAN, random) +} + +function generateNoise(length: number, disorder: number, random: Random): string { + let output = '' + let previous = '' + const repeatChance = 0.02 + disorder * 0.0015 + + for (let index = 0; index < length; index += 1) { + const shouldRepeat = previous !== '' && random() < repeatChance + const glyph = shouldRepeat ? previous : chooseNoiseGlyph(random) + + output += glyph + previous = glyph + } + + return output +} + +function pushHiddenParts(parts: OutputPart[], text: string): void { + const lines = text.split(/(\r?\n)/) + + for (const line of lines) { + if (line === '\n' || line === '\r\n') { + parts.push({ + type: 'newline', + text: '\n', + originalLength: 0, + noiseLength: 0, + weight: 0, + }) + continue + } + + const length = countGraphemes(line) + + if (length > 0) { + parts.push({ + type: 'hidden', + text: line, + originalLength: length, + noiseLength: 0, + weight: 0, + }) + } + } +} + +function buildOutputParts( + text: string, + retainedRanges: KeywordCandidate[], +): OutputPart[] { + const parts: OutputPart[] = [] + let cursor = 0 + + for (const range of retainedRanges) { + if (range.start > cursor) { + pushHiddenParts(parts, text.slice(cursor, range.start)) + } + + parts.push({ + type: 'retained', + text: text.slice(range.start, range.end), + originalLength: countGraphemes(text.slice(range.start, range.end)), + noiseLength: 0, + weight: 0, + }) + cursor = range.end + } + + if (cursor < text.length) { + pushHiddenParts(parts, text.slice(cursor)) + } + + return parts +} + +function distributeNoiseBudget( + parts: OutputPart[], + budget: number, + disorder: number, + random: Random, +): void { + const hiddenParts = parts.filter( + (part) => part.type === 'hidden' && part.originalLength > 0, + ) + + if (hiddenParts.length === 0 || budget <= 0) { + return + } + + const baseAllocation = budget >= hiddenParts.length ? 1 : 0 + let remaining = budget - baseAllocation * hiddenParts.length + const variability = disorder / 100 + + for (const part of hiddenParts) { + const localScale = 1 + (random() * 2 - 1) * variability * 0.62 + part.weight = Math.max(0.05, part.originalLength * localScale) + part.noiseLength = baseAllocation + } + + if (remaining <= 0) { + for (let index = 0; index < budget; index += 1) { + hiddenParts[index].noiseLength = 1 + } + return + } + + const totalWeight = hiddenParts.reduce((sum, part) => sum + part.weight, 0) + const allocations = hiddenParts.map((part) => { + const exact = (part.weight / totalWeight) * remaining + const whole = Math.floor(exact) + part.noiseLength += whole + return { part, fraction: exact - whole } + }) + const allocated = allocations.reduce( + (sum, allocation) => sum + Math.floor((allocation.part.weight / totalWeight) * remaining), + 0, + ) + + remaining -= allocated + allocations.sort((left, right) => right.fraction - left.fraction) + + for (let index = 0; index < remaining; index += 1) { + allocations[index % allocations.length].part.noiseLength += 1 + } +} + +export function generateMosaic( + text: string, + candidates: KeywordCandidate[], + options: MosaicOptions, +): MosaicResult { + const random = createRandom(options.seed ^ hashText(text)) + const retainedRanges = candidates + .filter((candidate) => candidate.selected) + .sort((left, right) => left.start - right.start) + const parts = buildOutputParts(text, retainedRanges) + const inputLength = countGraphemes(text) + const retainedLength = parts + .filter((part) => part.type === 'retained') + .reduce((sum, part) => sum + part.originalLength, 0) + const jitter = (random() * 2 - 1) * (options.disorder / 100) * 0.035 + const targetLength = Math.max( + retainedLength, + Math.round(inputLength * (options.volume / 100 + jitter)), + ) + const noiseBudget = Math.max(0, targetLength - retainedLength) + + distributeNoiseBudget(parts, noiseBudget, options.disorder, random) + + const output = parts + .map((part) => { + if (part.type === 'hidden') { + return generateNoise(part.noiseLength, options.disorder, random) + } + + return part.text + }) + .join('') + + return { + output, + inputLength, + outputLength: countGraphemes(output), + retainedLength, + } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..3860b8b --- /dev/null +++ b/src/style.css @@ -0,0 +1,637 @@ +:root { + color: #171815; + background: #e9e7dd; + color-scheme: light dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + --ink: #171815; + --paper: #e9e7dd; + --acid: #d8ff52; + --muted: #6b6b63; + --line: rgba(23, 24, 21, 0.24); + --grid-row: rgba(23, 24, 21, 0.035); + --grid-column: rgba(23, 24, 21, 0.025); + --panel: rgba(244, 242, 234, 0.76); + --panel-soft: rgba(244, 242, 234, 0.7); + --shadow: var(--ink); + --focus-inset: rgba(23, 24, 21, 0.28); + --placeholder: #9b9a91; + --output: #171815; + --output-ink: #efeee6; + --output-muted: #9c9d93; + --output-empty: #77786f; + --output-line: rgba(255, 255, 255, 0.2); + --output-glow: rgba(216, 255, 82, 0.08); + --output-hover: #efeee6; + --output-hover-ink: #171815; + --acid-hover: #efffb4; + --on-acid: #171815; + --selected: var(--ink); + --selected-ink: var(--acid); +} + +@media (prefers-color-scheme: dark) { + :root { + color: #efeee6; + background: #121310; + --ink: #efeee6; + --paper: #121310; + --acid: #cffa4c; + --muted: #a2a39a; + --line: rgba(239, 238, 230, 0.23); + --grid-row: rgba(239, 238, 230, 0.04); + --grid-column: rgba(239, 238, 230, 0.03); + --panel: rgba(29, 30, 26, 0.82); + --panel-soft: rgba(29, 30, 26, 0.76); + --shadow: #050604; + --focus-inset: rgba(239, 238, 230, 0.3); + --placeholder: #8e8f86; + --output: #090a08; + --output-glow: rgba(207, 250, 76, 0.11); + --acid-hover: #e7ffa0; + --selected: var(--acid); + --selected-ink: var(--on-acid); + } +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: var(--paper); +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + linear-gradient(var(--grid-row) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-column) 1px, transparent 1px), + var(--paper); + background-size: 100% 48px, 48px 100%, auto; +} + +button, +textarea, +input { + font: inherit; +} + +button { + color: inherit; +} + +button:focus-visible, +input:focus-visible { + outline: 3px solid var(--acid); + outline-offset: 3px; +} + +textarea:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--focus-inset); +} + +.page-shell { + width: min(1440px, 100%); + margin: 0 auto; + padding: 32px clamp(20px, 4vw, 64px) 28px; +} + +.hero { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto 1fr; + row-gap: clamp(20px, 3vw, 38px); + min-height: 220px; + padding: 22px 0 30px; + border-top: 1px solid var(--ink); +} + +.hero__index { + grid-column: 1 / -1; + grid-row: 1; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + letter-spacing: 0.14em; +} + +.hero__content { + grid-column: 1; + grid-row: 2; + align-self: end; +} + +.hero h1 { + margin: 0; + font-family: ui-serif, "Noto Serif SC", "Songti SC", serif; + font-size: clamp(58px, 9vw, 132px); + font-weight: 800; + line-height: 0.88; + letter-spacing: -0.08em; +} + +.hero p { + max-width: 560px; + margin: 25px 0 0 6px; + color: var(--muted); + font-family: ui-serif, "Noto Serif SC", "Songti SC", serif; + font-size: clamp(16px, 2vw, 22px); + letter-spacing: 0.08em; +} + +.hero__mark { + grid-column: 2; + grid-row: 2; + align-self: end; + padding-bottom: 2px; + font-size: clamp(64px, 9vw, 124px); + font-weight: 300; + line-height: 0.8; +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + border: 1px solid var(--ink); + box-shadow: 9px 9px 0 var(--shadow); +} + +.panel { + min-height: 460px; +} + +.panel--input { + display: flex; + flex-direction: column; + background: var(--panel); +} + +.panel--output { + display: flex; + flex-direction: column; + background: + radial-gradient(circle at 15% 18%, var(--output-glow), transparent 25%), + var(--output); + color: var(--output-ink); + border-left: 1px solid var(--ink); +} + +.panel__heading { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 76px; + padding: 18px 22px; + border-bottom: 1px solid var(--line); +} + +.panel__heading > div { + display: flex; + align-items: baseline; + gap: 12px; +} + +.panel__heading--dark { + border-color: var(--output-line); +} + +.panel__heading h2, +.keyword-stage h2, +.control-deck h2 { + margin: 0; + font-size: 19px; + font-weight: 650; + letter-spacing: 0.06em; +} + +.panel__number { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; +} + +.panel--output .panel__number { + color: var(--output-muted); +} + +.counter { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; +} + +.panel--output .counter { + color: var(--output-muted); +} + +textarea { + flex: 1; + width: 100%; + min-height: 340px; + padding: 28px 30px 34px; + resize: none; + color: var(--ink); + background: transparent; + border: 0; + font-family: ui-serif, "Noto Serif SC", "Songti SC", serif; + font-size: clamp(20px, 2.1vw, 28px); + line-height: 1.85; + letter-spacing: 0.04em; +} + +textarea::placeholder { + color: var(--placeholder); +} + +.mosaic-output { + flex: 1; + min-height: 316px; + padding: 30px; + overflow-wrap: anywhere; + font-family: + "Noto Serif SC", "Songti SC", ui-serif, "Courier New", serif; + font-size: clamp(21px, 2.25vw, 31px); + line-height: 1.75; + letter-spacing: 0.09em; + white-space: pre-wrap; +} + +.mosaic-output--empty { + color: var(--output-empty); +} + +.output-actions { + display: grid; + grid-template-columns: 1fr 1fr; + border-top: 1px solid var(--output-line); +} + +.button { + min-height: 64px; + padding: 12px 20px; + cursor: pointer; + border: 0; + font-weight: 700; + letter-spacing: 0.06em; + transition: + color 160ms ease, + background 160ms ease; +} + +.button--light { + background: var(--acid); + color: var(--on-acid); +} + +.button--light:hover { + background: var(--acid-hover); +} + +.button--outline { + color: var(--output-ink); + background: transparent; + border-left: 1px solid var(--output-line); +} + +.button--outline:hover { + color: var(--output-hover-ink); + background: var(--output-hover); +} + +.control-deck { + display: grid; + grid-template-columns: minmax(160px, 0.7fr) 2fr; + gap: 40px; + margin-top: 76px; + padding: 26px 0 38px; + border-top: 1px solid var(--ink); + border-bottom: 1px solid var(--ink); +} + +.control-deck__title > span { + display: block; + margin-bottom: 14px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + letter-spacing: 0.13em; +} + +.controls { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 34px; +} + +.control { + display: block; +} + +.control__heading { + display: flex; + justify-content: space-between; + font-weight: 720; +} + +.control__heading output { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 500; +} + +.control__hint { + display: block; + min-height: 36px; + margin-top: 7px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +input[type="range"] { + width: 100%; + height: 18px; + margin-top: 18px; + cursor: pointer; + appearance: none; + background: transparent; +} + +input[type="range"]::-webkit-slider-runnable-track { + height: 2px; + background: var(--ink); +} + +input[type="range"]::-moz-range-track { + height: 2px; + background: var(--ink); +} + +input[type="range"]::-webkit-slider-thumb { + width: 18px; + height: 18px; + margin-top: -8px; + appearance: none; + background: var(--acid); + border: 2px solid var(--ink); + border-radius: 0; +} + +input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--acid); + border: 2px solid var(--ink); + border-radius: 0; +} + +.keyword-stage { + margin-top: 74px; +} + +.keyword-stage__heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; +} + +.keyword-stage__heading > div { + display: grid; + grid-template-columns: auto 1fr; + align-items: baseline; + gap: 0 12px; +} + +.keyword-stage__heading p { + grid-column: 2; + margin: 9px 0 0; + color: var(--muted); + font-size: 13px; +} + +.text-button { + padding: 8px 0; + cursor: pointer; + background: transparent; + border: 0; + border-bottom: 1px solid var(--ink); + font-size: 13px; + font-weight: 650; +} + +.text-button:hover { + border-bottom-width: 3px; +} + +.keyword-list { + display: flex; + flex-wrap: wrap; + gap: 9px; + min-height: 110px; + margin-top: 24px; + padding: 24px; + background: var(--panel-soft); + border: 1px solid var(--ink); +} + +.keyword-token { + min-width: 46px; + padding: 9px 13px; + cursor: pointer; + background: transparent; + border: 1px solid var(--line); + font-family: ui-serif, "Noto Serif SC", "Songti SC", serif; + font-size: 15px; + transition: + transform 120ms ease, + color 120ms ease, + background 120ms ease; +} + +.keyword-token:hover { + transform: translateY(-2px); + border-color: var(--ink); +} + +.keyword-token--selected { + color: var(--selected-ink); + background: var(--selected); + border-color: var(--selected); +} + +.keyword-list__empty { + margin: auto; + color: var(--muted); + font-size: 13px; +} + +footer { + display: flex; + justify-content: space-between; + gap: 24px; + margin-top: 96px; + padding-top: 18px; + color: var(--muted); + border-top: 1px solid var(--line); + font-size: 11px; + letter-spacing: 0.08em; +} + +footer p { + margin: 0; +} + +.toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 10; + padding: 13px 18px; + color: var(--on-acid); + background: var(--acid); + border: 1px solid var(--ink); + box-shadow: 5px 5px 0 var(--shadow); + font-size: 13px; + font-weight: 700; + opacity: 0; + transform: translateY(18px); + pointer-events: none; + transition: + opacity 180ms ease, + transform 180ms ease; +} + +.toast--visible { + opacity: 1; + transform: translateY(0); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 860px) { + .page-shell { + padding-top: 20px; + } + + .hero { + min-height: 190px; + } + + .hero__mark { + display: none; + } + + .workspace { + grid-template-columns: 1fr; + box-shadow: 6px 6px 0 var(--shadow); + } + + .panel { + min-height: 400px; + } + + .panel--output { + border-top: 1px solid var(--ink); + border-left: 0; + } + + .control-deck { + grid-template-columns: 1fr; + gap: 30px; + margin-top: 64px; + } +} + +@media (max-width: 640px) { + .page-shell { + padding-inline: 14px; + } + + .hero { + min-height: 164px; + padding-bottom: 22px; + } + + .hero h1 { + font-size: clamp(52px, 17vw, 78px); + } + + .hero p { + margin-top: 18px; + font-size: 14px; + } + + .panel { + min-height: 360px; + } + + .panel__heading { + min-height: 66px; + padding: 15px 17px; + } + + textarea, + .mosaic-output { + min-height: 290px; + padding: 22px 18px; + font-size: 20px; + } + + .controls { + grid-template-columns: 1fr; + gap: 30px; + } + + .control__hint { + min-height: auto; + } + + .keyword-stage { + margin-top: 58px; + } + + .keyword-stage__heading { + align-items: start; + } + + .keyword-stage__heading p { + grid-column: 1 / -1; + } + + .keyword-list { + padding: 16px; + } + + footer { + flex-direction: column; + margin-top: 70px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..48b68b0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable", "ES2022.Intl"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +}