Compare commits

..

8 Commits

8 changed files with 44 additions and 184 deletions
-5
View File
@@ -1,5 +0,0 @@
.git
.worktrees
dist
node_modules
*.log
-16
View File
@@ -1,16 +0,0 @@
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
+1 -10
View File
@@ -7,16 +7,7 @@
name="description" name="description"
content="将中文文本侵蚀成保留少量关键词的纯文本乱码艺术。" content="将中文文本侵蚀成保留少量关键词的纯文本乱码艺术。"
/> />
<meta <meta name="theme-color" content="#e9e7dd" />
name="theme-color"
content="#e9e7dd"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#121310"
media="(prefers-color-scheme: dark)"
/>
<title>文字马赛克</title> <title>文字马赛克</title>
</head> </head>
<body> <body>
-28
View File
@@ -1,28 +0,0 @@
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;
}
}
+3 -10
View File
@@ -74,10 +74,10 @@ app.innerHTML = `
<label class="control" for="retention-control"> <label class="control" for="retention-control">
<span class="control__heading"> <span class="control__heading">
<span>显影</span> <span>显影</span>
<output id="retention-value">15%</output> <output id="retention-value">14%</output>
</span> </span>
<span class="control__hint">留下多少原文词语</span> <span class="control__hint">留下多少原文词语</span>
<input id="retention-control" type="range" min="0" max="100" value="15" /> <input id="retention-control" type="range" min="5" max="40" value="14" />
</label> </label>
<label class="control" for="volume-control"> <label class="control" for="volume-control">
@@ -86,14 +86,7 @@ app.innerHTML = `
<output id="volume-value">100%</output> <output id="volume-value">100%</output>
</span> </span>
<span class="control__hint">结果相对原文的总长度</span> <span class="control__hint">结果相对原文的总长度</span>
<input <input id="volume-control" type="range" min="85" max="115" value="100" />
id="volume-control"
type="range"
min="10"
max="1000"
step="10"
value="100"
/>
</label> </label>
<label class="control" for="disorder-control"> <label class="control" for="disorder-control">
-36
View File
@@ -36,14 +36,6 @@ describe('selectKeywords', () => {
expect(candidates.some((candidate) => candidate.selected)).toBe(true) 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', () => { describe('generateMosaic', () => {
@@ -82,34 +74,6 @@ describe('generateMosaic', () => {
expect(ratio).toBeLessThanOrEqual(1.03) 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('相同种子可复现,不同种子会重新侵蚀', () => { it('相同种子可复现,不同种子会重新侵蚀', () => {
const candidates = selectKeywords(SOURCE, 14, 1) const candidates = selectKeywords(SOURCE, 14, 1)
const first = generateMosaic(SOURCE, candidates, { const first = generateMosaic(SOURCE, candidates, {
+11 -6
View File
@@ -237,6 +237,8 @@ const ENCODING_FRAGMENTS = ['Ã', 'Â', 'æ', 'å', '¤', '锟', '斤', '拷'] a
const COMPLEX_HAN = ['黻', '鬯', '夔', '爨', '龘', '靐', '罅', '黹'] as const const COMPLEX_HAN = ['黻', '鬯', '夔', '爨', '龘', '靐', '罅', '黹'] as const
const COMBINING_MARKS = ['\u0336', '\u0337', '\u0338', '\u035F'] as const
const HAN_PATTERN = /\p{Script=Han}/u const HAN_PATTERN = /\p{Script=Han}/u
const WORD_PATTERN = /[\p{L}\p{N}]/u const WORD_PATTERN = /[\p{L}\p{N}]/u
@@ -343,12 +345,12 @@ export function selectKeywords(
return candidates return candidates
} }
const desiredCount = const desiredCount = Math.min(
retention <= 0
? 0
: Math.min(
candidates.length, candidates.length,
Math.max(1, Math.ceil(candidates.length * (retention / 100))), Math.max(
candidates.length >= 4 ? 2 : 1,
Math.round(candidates.length * (retention / 100)),
),
) )
const ranked = [...candidates].sort( const ranked = [...candidates].sort(
(left, right) => right.score - left.score || left.start - right.start, (left, right) => right.score - left.score || left.start - right.start,
@@ -393,12 +395,15 @@ function generateNoise(length: number, disorder: number, random: Random): string
let output = '' let output = ''
let previous = '' let previous = ''
const repeatChance = 0.02 + disorder * 0.0015 const repeatChance = 0.02 + disorder * 0.0015
const combiningChance = 0.02 + disorder * 0.0011
for (let index = 0; index < length; index += 1) { for (let index = 0; index < length; index += 1) {
const shouldRepeat = previous !== '' && random() < repeatChance const shouldRepeat = previous !== '' && random() < repeatChance
const glyph = shouldRepeat ? previous : chooseNoiseGlyph(random) const glyph = shouldRepeat ? previous : chooseNoiseGlyph(random)
const combining =
random() < combiningChance ? choose(COMBINING_MARKS, random) : ''
output += glyph output += `${glyph}${combining}`
previous = glyph previous = glyph
} }
+27 -71
View File
@@ -1,7 +1,6 @@
:root { :root {
color: #171815; color: #171815;
background: #e9e7dd; background: #e9e7dd;
color-scheme: light dark;
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif; sans-serif;
@@ -12,49 +11,6 @@
--acid: #d8ff52; --acid: #d8ff52;
--muted: #6b6b63; --muted: #6b6b63;
--line: rgba(23, 24, 21, 0.24); --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);
}
} }
* { * {
@@ -72,8 +28,8 @@ body {
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: background:
linear-gradient(var(--grid-row) 1px, transparent 1px), linear-gradient(rgba(23, 24, 21, 0.035) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-column) 1px, transparent 1px), linear-gradient(90deg, rgba(23, 24, 21, 0.025) 1px, transparent 1px),
var(--paper); var(--paper);
background-size: 100% 48px, 48px 100%, auto; background-size: 100% 48px, 48px 100%, auto;
} }
@@ -96,7 +52,7 @@ input:focus-visible {
textarea:focus-visible { textarea:focus-visible {
outline: none; outline: none;
box-shadow: inset 0 0 0 1px var(--focus-inset); box-shadow: inset 0 0 0 1px rgba(23, 24, 21, 0.28);
} }
.page-shell { .page-shell {
@@ -161,7 +117,7 @@ textarea:focus-visible {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
border: 1px solid var(--ink); border: 1px solid var(--ink);
box-shadow: 9px 9px 0 var(--shadow); box-shadow: 9px 9px 0 var(--ink);
} }
.panel { .panel {
@@ -171,16 +127,16 @@ textarea:focus-visible {
.panel--input { .panel--input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--panel); background: rgba(244, 242, 234, 0.76);
} }
.panel--output { .panel--output {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: background:
radial-gradient(circle at 15% 18%, var(--output-glow), transparent 25%), radial-gradient(circle at 15% 18%, rgba(216, 255, 82, 0.08), transparent 25%),
var(--output); var(--ink);
color: var(--output-ink); color: #efeee6;
border-left: 1px solid var(--ink); border-left: 1px solid var(--ink);
} }
@@ -200,7 +156,7 @@ textarea:focus-visible {
} }
.panel__heading--dark { .panel__heading--dark {
border-color: var(--output-line); border-color: rgba(255, 255, 255, 0.2);
} }
.panel__heading h2, .panel__heading h2,
@@ -219,7 +175,7 @@ textarea:focus-visible {
} }
.panel--output .panel__number { .panel--output .panel__number {
color: var(--output-muted); color: #9c9d93;
} }
.counter { .counter {
@@ -229,7 +185,7 @@ textarea:focus-visible {
} }
.panel--output .counter { .panel--output .counter {
color: var(--output-muted); color: #9c9d93;
} }
textarea { textarea {
@@ -248,7 +204,7 @@ textarea {
} }
textarea::placeholder { textarea::placeholder {
color: var(--placeholder); color: #9b9a91;
} }
.mosaic-output { .mosaic-output {
@@ -265,13 +221,13 @@ textarea::placeholder {
} }
.mosaic-output--empty { .mosaic-output--empty {
color: var(--output-empty); color: #77786f;
} }
.output-actions { .output-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
border-top: 1px solid var(--output-line); border-top: 1px solid rgba(255, 255, 255, 0.2);
} }
.button { .button {
@@ -288,22 +244,22 @@ textarea::placeholder {
.button--light { .button--light {
background: var(--acid); background: var(--acid);
color: var(--on-acid); color: var(--ink);
} }
.button--light:hover { .button--light:hover {
background: var(--acid-hover); background: #efffb4;
} }
.button--outline { .button--outline {
color: var(--output-ink); color: #efeee6;
background: transparent; background: transparent;
border-left: 1px solid var(--output-line); border-left: 1px solid rgba(255, 255, 255, 0.2);
} }
.button--outline:hover { .button--outline:hover {
color: var(--output-hover-ink); color: var(--ink);
background: var(--output-hover); background: #efeee6;
} }
.control-deck { .control-deck {
@@ -439,7 +395,7 @@ input[type="range"]::-moz-range-thumb {
min-height: 110px; min-height: 110px;
margin-top: 24px; margin-top: 24px;
padding: 24px; padding: 24px;
background: var(--panel-soft); background: rgba(244, 242, 234, 0.7);
border: 1px solid var(--ink); border: 1px solid var(--ink);
} }
@@ -463,9 +419,9 @@ input[type="range"]::-moz-range-thumb {
} }
.keyword-token--selected { .keyword-token--selected {
color: var(--selected-ink); color: var(--acid);
background: var(--selected); background: var(--ink);
border-color: var(--selected); border-color: var(--ink);
} }
.keyword-list__empty { .keyword-list__empty {
@@ -496,10 +452,10 @@ footer p {
bottom: 24px; bottom: 24px;
z-index: 10; z-index: 10;
padding: 13px 18px; padding: 13px 18px;
color: var(--on-acid); color: var(--ink);
background: var(--acid); background: var(--acid);
border: 1px solid var(--ink); border: 1px solid var(--ink);
box-shadow: 5px 5px 0 var(--shadow); box-shadow: 5px 5px 0 var(--ink);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
opacity: 0; opacity: 0;
@@ -542,7 +498,7 @@ footer p {
.workspace { .workspace {
grid-template-columns: 1fr; grid-template-columns: 1fr;
box-shadow: 6px 6px 0 var(--shadow); box-shadow: 6px 6px 0 var(--ink);
} }
.panel { .panel {