diff --git a/.gitignore b/.gitignore
index 2309cc8..3b2ccbe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
# ---> Node
+.worktrees/
+
# Logs
logs
*.log
@@ -135,4 +137,3 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
-
diff --git a/README.md b/README.md
index 43d45e0..1614ff5 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,29 @@
-# text-mosaic
+# 文字马赛克
-将中文文本转化为保留少量关键词的乱码缺损艺术
\ No newline at end of file
+将中文文本转化为保留少量关键词的乱码缺损艺术。所有处理均在浏览器本地完成。
+
+## 特性
+
+- 使用浏览器原生中文分词自动挑选保留词
+- 支持手动切换每个候选词的显影状态
+- 标点和未保留文本会被完全替换
+- 混合低笔画少用汉字、故障符号、异文字母和编码残片
+- 总体长度接近原文,同时允许局部片段随机伸缩
+- 同一组参数可反复生成不同结果并复制为纯文本
+
+## 本地开发
+
+```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..f64c7c6
--- /dev/null
+++ b/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ 文字马赛克
+
+
+
+
+
+
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..77870ab
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,280 @@
+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 = `
+
+
+ TEXT / EROSION / 01
+
+
文字马赛克
+
让文字损坏,只留下几处可辨认的痕迹。
+
+ ※
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
03
+
保留词
+
点按词语切换显影状态,原始顺序不会改变。
+
+
+
+
+
+
+
+
+
+
+
+`
+
+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..dd0bb1a
--- /dev/null
+++ b/src/mosaic.test.ts
@@ -0,0 +1,111 @@
+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)
+ })
+})
+
+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('相同种子可复现,不同种子会重新侵蚀', () => {
+ 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..113490e
--- /dev/null
+++ b/src/mosaic.ts
@@ -0,0 +1,561 @@
+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 COMBINING_MARKS = ['\u0336', '\u0337', '\u0338', '\u035F'] 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 = Math.min(
+ candidates.length,
+ Math.max(
+ candidates.length >= 4 ? 2 : 1,
+ Math.round(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
+ const combiningChance = 0.02 + disorder * 0.0011
+
+ for (let index = 0; index < length; index += 1) {
+ const shouldRepeat = previous !== '' && random() < repeatChance
+ const glyph = shouldRepeat ? previous : chooseNoiseGlyph(random)
+ const combining =
+ random() < combiningChance ? choose(COMBINING_MARKS, random) : ''
+
+ output += `${glyph}${combining}`
+ 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..1622e9f
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,585 @@
+:root {
+ color: #171815;
+ background: #e9e7dd;
+ 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);
+}
+
+* {
+ 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(rgba(23, 24, 21, 0.035) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(23, 24, 21, 0.025) 1px, transparent 1px),
+ var(--paper);
+ background-size: 100% 48px, 48px 100%, auto;
+}
+
+button,
+textarea,
+input {
+ font: inherit;
+}
+
+button {
+ color: inherit;
+}
+
+button:focus-visible,
+textarea:focus-visible,
+input:focus-visible {
+ outline: 3px solid var(--acid);
+ outline-offset: 3px;
+}
+
+.page-shell {
+ width: min(1440px, 100%);
+ margin: 0 auto;
+ padding: 32px clamp(20px, 4vw, 64px) 28px;
+}
+
+.hero {
+ position: relative;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ min-height: 220px;
+ padding: 22px 0 30px;
+ border-top: 1px solid var(--ink);
+}
+
+.hero__index {
+ position: absolute;
+ top: 22px;
+ left: 0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 11px;
+ letter-spacing: 0.14em;
+}
+
+.hero__content {
+ 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 {
+ 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(--ink);
+}
+
+.panel {
+ min-height: 460px;
+}
+
+.panel--input {
+ display: flex;
+ flex-direction: column;
+ background: rgba(244, 242, 234, 0.76);
+}
+
+.panel--output {
+ display: flex;
+ flex-direction: column;
+ background:
+ radial-gradient(circle at 15% 18%, rgba(216, 255, 82, 0.08), transparent 25%),
+ var(--ink);
+ color: #efeee6;
+ 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: rgba(255, 255, 255, 0.2);
+}
+
+.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: #9c9d93;
+}
+
+.counter {
+ color: var(--muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 11px;
+}
+
+.panel--output .counter {
+ color: #9c9d93;
+}
+
+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: #9b9a91;
+}
+
+.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: #77786f;
+}
+
+.output-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ border-top: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.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(--ink);
+}
+
+.button--light:hover {
+ background: #efffb4;
+}
+
+.button--outline {
+ color: #efeee6;
+ background: transparent;
+ border-left: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.button--outline:hover {
+ color: var(--ink);
+ background: #efeee6;
+}
+
+.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: rgba(244, 242, 234, 0.7);
+ 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(--acid);
+ background: var(--ink);
+ border-color: var(--ink);
+}
+
+.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(--ink);
+ background: var(--acid);
+ border: 1px solid var(--ink);
+ box-shadow: 5px 5px 0 var(--ink);
+ 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(--ink);
+ }
+
+ .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"]
+}