recreate project

This commit is contained in:
2026-08-01 11:53:13 +08:00
commit 70976ff03a
17 changed files with 1970 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
.worktrees
dist
node_modules
*.log
+139
View File
@@ -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.*
+16
View File
@@ -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
+18
View File
@@ -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.
+29
View File
@@ -0,0 +1,29 @@
# 文字马赛克
将中文文本转化为保留少量关键词的乱码缺损艺术。所有处理均在浏览器本地完成。
## 特性
- 使用浏览器原生中文分词自动挑选保留词
- 支持手动切换每个候选词的显影状态
- 标点和未保留文本会被完全替换
- 混合低笔画少用汉字、故障符号、异文字母和编码残片
- 总体长度接近原文,同时允许局部片段随机伸缩
- 同一组参数可反复生成不同结果并复制为纯文本
## 本地开发
```bash
bun run dev
```
## 验证
```bash
bun test
bun run build
```
## 许可证
[MIT](./LICENSE)
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="将中文文本侵蚀成保留少量关键词的纯文本乱码艺术。"
/>
<meta
name="theme-color"
content="#e9e7dd"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#121310"
media="(prefers-color-scheme: dark)"
/>
<title>文字马赛克</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+28
View File
@@ -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;
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "text-mosaic",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "text-mosaic",
"version": "0.1.0"
}
}
}
+11
View File
@@ -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"
}
}
+27
View File
@@ -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/`)
+11
View File
@@ -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}`)
+1
View File
@@ -0,0 +1 @@
declare module '*.css'
+287
View File
@@ -0,0 +1,287 @@
import './style.css'
import {
generateMosaic,
selectKeywords,
type KeywordCandidate,
} from './mosaic'
const SAMPLE_TEXT =
'我一直以为记忆会忠实地保存一切,后来才发现,每一次回想都在重新编造过去。'
const app = document.querySelector<HTMLDivElement>('#app')
if (!app) {
throw new Error('找不到应用挂载节点 #app')
}
app.innerHTML = `
<div class="page-shell">
<header class="hero">
<div class="hero__index" aria-hidden="true">TEXT / EROSION / 01</div>
<div class="hero__content">
<h1>文字马赛克</h1>
<p>让文字损坏,只留下几处可辨认的痕迹。</p>
</div>
<div class="hero__mark" aria-hidden="true">※</div>
</header>
<main>
<section class="workspace" aria-label="文字马赛克生成器">
<article class="panel panel--input">
<div class="panel__heading">
<div>
<span class="panel__number">01</span>
<h2>原始文本</h2>
</div>
<span id="input-count" class="counter">0 字</span>
</div>
<label class="sr-only" for="source-text">输入原始文本</label>
<textarea
id="source-text"
maxlength="3000"
spellcheck="false"
placeholder="在这里粘贴一段中文……"
></textarea>
</article>
<article class="panel panel--output">
<div class="panel__heading panel__heading--dark">
<div>
<span class="panel__number">02</span>
<h2>损坏结果</h2>
</div>
<span id="output-ratio" class="counter">—</span>
</div>
<div id="mosaic-output" class="mosaic-output" role="status"></div>
<div class="output-actions">
<button id="reroll-button" class="button button--light" type="button">
换一版
</button>
<button id="copy-button" class="button button--outline" type="button">
复制文本
</button>
</div>
</article>
</section>
<section class="control-deck" aria-label="生成参数">
<div class="control-deck__title">
<span>CONTROL SIGNAL</span>
<h2>侵蚀参数</h2>
</div>
<div class="controls">
<label class="control" for="retention-control">
<span class="control__heading">
<span>显影</span>
<output id="retention-value">15%</output>
</span>
<span class="control__hint">留下多少原文词语</span>
<input id="retention-control" type="range" min="0" max="100" value="15" />
</label>
<label class="control" for="volume-control">
<span class="control__heading">
<span>体量</span>
<output id="volume-value">100%</output>
</span>
<span class="control__hint">结果相对原文的总长度</span>
<input
id="volume-control"
type="range"
min="10"
max="1000"
step="10"
value="100"
/>
</label>
<label class="control" for="disorder-control">
<span class="control__heading">
<span>扰动</span>
<output id="disorder-value">58%</output>
</span>
<span class="control__hint">局部伸缩、重复与叠字强度</span>
<input id="disorder-control" type="range" min="0" max="100" value="58" />
</label>
</div>
</section>
<section class="keyword-stage" aria-labelledby="keyword-title">
<div class="keyword-stage__heading">
<div>
<span class="panel__number">03</span>
<h2 id="keyword-title">保留词</h2>
<p>点按词语切换显影状态,原始顺序不会改变。</p>
</div>
<button id="reselect-button" class="text-button" type="button">自动重选</button>
</div>
<div id="keyword-list" class="keyword-list"></div>
</section>
</main>
<footer>
<span>LOCAL PROCESSING</span>
<p>文本只在当前浏览器中处理,不会上传。</p>
</footer>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
`
const sourceInput = getElement<HTMLTextAreaElement>('source-text')
const inputCount = getElement<HTMLSpanElement>('input-count')
const outputRatio = getElement<HTMLSpanElement>('output-ratio')
const mosaicOutput = getElement<HTMLDivElement>('mosaic-output')
const keywordList = getElement<HTMLDivElement>('keyword-list')
const toast = getElement<HTMLDivElement>('toast')
const retentionControl = getElement<HTMLInputElement>('retention-control')
const volumeControl = getElement<HTMLInputElement>('volume-control')
const disorderControl = getElement<HTMLInputElement>('disorder-control')
const retentionValue = getElement<HTMLOutputElement>('retention-value')
const volumeValue = getElement<HTMLOutputElement>('volume-value')
const disorderValue = getElement<HTMLOutputElement>('disorder-value')
const rerollButton = getElement<HTMLButtonElement>('reroll-button')
const copyButton = getElement<HTMLButtonElement>('copy-button')
const reselectButton = getElement<HTMLButtonElement>('reselect-button')
let candidates: KeywordCandidate[] = []
let selectionSeed = 5
let noiseSeed = Date.now()
let toastTimer = 0
function getElement<T extends HTMLElement>(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()
+147
View File
@@ -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)
})
})
+556
View File
@@ -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<string, number>()
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<T>(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,
}
}
+637
View File
@@ -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;
}
}
+20
View File
@@ -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"]
}