From c580d069b17024c0131a90af8eade5c9c0fea0d4 Mon Sep 17 00:00:00 2001 From: necobot Date: Sat, 25 Jul 2026 16:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E6=96=87?= =?UTF-8?q?=E5=AD=97=E9=A9=AC=E8=B5=9B=E5=85=8B=E7=94=9F=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- README.md | 30 ++- index.html | 17 ++ package.json | 11 + scripts/build.ts | 27 +++ scripts/dev.ts | 11 + src/assets.d.ts | 1 + src/main.ts | 280 ++++++++++++++++++++++ src/mosaic.test.ts | 111 +++++++++ src/mosaic.ts | 561 +++++++++++++++++++++++++++++++++++++++++++ src/style.css | 585 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 20 ++ 12 files changed, 1654 insertions(+), 3 deletions(-) create mode 100644 index.html create mode 100644 package.json create mode 100644 scripts/build.ts create mode 100644 scripts/dev.ts create mode 100644 src/assets.d.ts create mode 100644 src/main.ts create mode 100644 src/mosaic.test.ts create mode 100644 src/mosaic.ts create mode 100644 src/style.css create mode 100644 tsconfig.json diff --git a/.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 = ` +
+
+ +
+

文字马赛克

+

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

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

原始文本

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

损坏结果

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

侵蚀参数

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

保留词

+

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

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

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

+
+
+ +
+` + +const sourceInput = getElement('source-text') +const inputCount = getElement('input-count') +const outputRatio = getElement('output-ratio') +const mosaicOutput = getElement('mosaic-output') +const keywordList = getElement('keyword-list') +const toast = getElement('toast') +const retentionControl = getElement('retention-control') +const volumeControl = getElement('volume-control') +const disorderControl = getElement('disorder-control') +const retentionValue = getElement('retention-value') +const volumeValue = getElement('volume-value') +const disorderValue = getElement('disorder-value') +const rerollButton = getElement('reroll-button') +const copyButton = getElement('copy-button') +const reselectButton = getElement('reselect-button') + +let candidates: KeywordCandidate[] = [] +let selectionSeed = 5 +let noiseSeed = Date.now() +let toastTimer = 0 + +function getElement(id: string): T { + const element = document.getElementById(id) + + if (!element) { + throw new Error(`找不到页面元素 #${id}`) + } + + return element as T +} + +function showToast(message: string): void { + window.clearTimeout(toastTimer) + toast.textContent = message + toast.classList.add('toast--visible') + toastTimer = window.setTimeout(() => { + toast.classList.remove('toast--visible') + }, 1800) +} + +function getRetention(): number { + return Number(retentionControl.value) +} + +function renderKeywords(): void { + keywordList.replaceChildren() + + if (candidates.length === 0) { + const empty = document.createElement('p') + empty.className = 'keyword-list__empty' + empty.textContent = sourceInput.value.trim() + ? '没有识别到可保留的词语,可以尝试输入更长的中文文本。' + : '输入文字后,这里会出现可保留的词语。' + keywordList.append(empty) + return + } + + for (const candidate of candidates) { + const button = document.createElement('button') + button.type = 'button' + button.className = 'keyword-token' + button.textContent = candidate.text + button.setAttribute('aria-pressed', String(candidate.selected)) + + if (candidate.selected) { + button.classList.add('keyword-token--selected') + } + + button.addEventListener('click', () => { + candidate.selected = !candidate.selected + renderKeywords() + renderOutput() + }) + keywordList.append(button) + } +} + +function refreshCandidates(): void { + candidates = selectKeywords(sourceInput.value, getRetention(), selectionSeed) + renderKeywords() + renderOutput() +} + +function renderOutput(): void { + const result = generateMosaic(sourceInput.value, candidates, { + volume: Number(volumeControl.value), + disorder: Number(disorderControl.value), + seed: noiseSeed, + }) + + mosaicOutput.textContent = result.output || '等待文字进入损坏区' + mosaicOutput.classList.toggle('mosaic-output--empty', result.output === '') + inputCount.textContent = `${result.inputLength} 字` + outputRatio.textContent = + result.inputLength === 0 + ? '—' + : `${result.outputLength} 字 / ${Math.round((result.outputLength / result.inputLength) * 100)}%` +} + +function syncControls(): void { + retentionValue.value = `${retentionControl.value}%` + volumeValue.value = `${volumeControl.value}%` + disorderValue.value = `${disorderControl.value}%` +} + +sourceInput.addEventListener('input', () => { + selectionSeed += 1 + noiseSeed += 1 + refreshCandidates() +}) + +retentionControl.addEventListener('input', () => { + syncControls() + refreshCandidates() +}) + +volumeControl.addEventListener('input', () => { + syncControls() + renderOutput() +}) + +disorderControl.addEventListener('input', () => { + syncControls() + renderOutput() +}) + +rerollButton.addEventListener('click', () => { + noiseSeed += 1 + renderOutput() +}) + +reselectButton.addEventListener('click', () => { + selectionSeed += 1 + refreshCandidates() +}) + +copyButton.addEventListener('click', async () => { + const text = mosaicOutput.textContent ?? '' + + if (!sourceInput.value || !text) { + showToast('还没有可以复制的结果') + return + } + + try { + await navigator.clipboard.writeText(text) + showToast('已复制纯文本') + } catch (error) { + console.error('复制文字马赛克失败', error) + showToast('复制失败,请手动选择文本') + } +}) + +sourceInput.value = SAMPLE_TEXT +syncControls() +refreshCandidates() diff --git a/src/mosaic.test.ts b/src/mosaic.test.ts new file mode 100644 index 0000000..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"] +}