Compare commits

..

1 Commits

Author SHA1 Message Date
mattuy 70976ff03a recreate project 2026-08-01 11:53:13 +08:00
16 changed files with 1814 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
.worktrees
dist
node_modules
*.log
+2 -1
View File
@@ -1,4 +1,6 @@
# ---> Node # ---> Node
.worktrees/
# Logs # Logs
logs logs
*.log *.log
@@ -135,4 +137,3 @@ dist
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .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
+28 -2
View File
@@ -1,3 +1,29 @@
# text-mosaic # 文字马赛克
将中文文本转化为保留少量关键词的乱码缺损艺术 将中文文本转化为保留少量关键词的乱码缺损艺术。所有处理均在浏览器本地完成。
## 特性
- 使用浏览器原生中文分词自动挑选保留词
- 支持手动切换每个候选词的显影状态
- 标点和未保留文本会被完全替换
- 混合低笔画少用汉字、故障符号、异文字母和编码残片
- 总体长度接近原文,同时允许局部片段随机伸缩
- 同一组参数可反复生成不同结果并复制为纯文本
## 本地开发
```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"]
}