Files
persona/scripts/persona-icon.mjs
T
jiantw83andClaude Opus 5 d067907225 feat: 建立人格並補齊資料後產生圖示(SVG + PNG,零外部依賴)
`icon generate|show`:由**編號、Name、Emoji** 雜湊出配色與圖案,產出
512×512 的幾何徽章 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。

圖案(SVG 與 PNG 共用同一組單位座標與同一份 5×7 點陣字,**輸出的是同一張圖**):
  * 雙色對角漸層底
  * 5×5 左右對稱點陣紋(identicon 式)
  * 中央兩個字母=編號前兩字(ASUNA-01 → AS),依背景亮度自動選黑/白

同一個人格永遠得到同一張圖(純函數,無隨機);ASUNA-01 與 ASUNA-02 明顯不同。

為什麼自己畫:這台機器(及多數伺服器)沒有 rsvg/inkscape/imagemagick,
沒有影像函式庫,也沒有 emoji 字型,而本專案禁止 npm 依賴。所以
scripts/persona-icon.mjs 自己柵格化(3× 超取樣 + 盒式縮減當反鋸齒),
再用內建 zlib 手工組出 IHDR/IDAT/IEND 與 CRC32。
emoji 無法柵格化,因此不作為圖形,但仍參與配色雜湊。

時機:**資料補齊之後才產生**——配色與字母綁在最終身分上,太早跑會對不上。
persona-create 第 7 步、persona-anime 第 8 步都補了這個步驟;改過身分用 --force 重畫。

selftest 128 項全綠(新增第 ⑭ 節:PNG chunk 合法性、決定性、不同人格不同圖、
圖示屬 Wiki 區、匯出用 base64 帶走二進位、guest 不得重畫)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:45 +00:00

404 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// persona-icon.mjs — 由人格資料產生圖示(SVG + PNG),零外部依賴
//
// 為什麼是「幾何圖形 + 字母」而不是 emoji:
// 這台機器(以及大部分伺服器)沒有 rsvg/inkscape/imagemagick,也沒有 emoji 字型,
// 而本專案的規則是只用 Node 內建模組。把 emoji 畫進 PNG 需要字型柵格化,做不到。
// 所以圖案只用「我能在 SVG 與自寫柵格器裡畫出完全相同結果」的元素:
// 圓角矩形、線性漸層、圓點、以及 5×7 點陣字母。**兩種格式輸出的是同一張圖。**
// emoji 仍然參與雜湊,所以它會影響配色。
//
// 圖案(512×512 圓角方形徽章):
// 底:由人格編號雜湊出的雙色對角漸層
// 紋:5×5 左右對稱的圓點(identicon 式,每個人格都不一樣)
// 字:編號英文名的前兩個字母(ASUNA-01 → AS),5×7 點陣,自動選黑或白以確保對比
//
// 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import crypto from "node:crypto";
import * as pl from "./persona-lib.mjs";
export const ICON_SVG = "icon.svg";
export const ICON_PNG = "icon.png";
export const DEFAULT_SIZE = 512;
export const iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG);
export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG);
// --------------------------------------------------------------------------- //
// 5×7 點陣字(SVG 與 PNG 共用同一份資料,兩邊才會長得一模一樣)
// --------------------------------------------------------------------------- //
const FONT = {
A: "01110,10001,10001,11111,10001,10001,10001",
B: "11110,10001,10001,11110,10001,10001,11110",
C: "01111,10000,10000,10000,10000,10000,01111",
D: "11110,10001,10001,10001,10001,10001,11110",
E: "11111,10000,10000,11110,10000,10000,11111",
F: "11111,10000,10000,11110,10000,10000,10000",
G: "01110,10001,10000,10111,10001,10001,01111",
H: "10001,10001,10001,11111,10001,10001,10001",
I: "11111,00100,00100,00100,00100,00100,11111",
J: "00111,00010,00010,00010,00010,10010,01100",
K: "10001,10010,10100,11000,10100,10010,10001",
L: "10000,10000,10000,10000,10000,10000,11111",
M: "10001,11011,10101,10101,10001,10001,10001",
N: "10001,11001,10101,10011,10001,10001,10001",
O: "01110,10001,10001,10001,10001,10001,01110",
P: "11110,10001,10001,11110,10000,10000,10000",
Q: "01110,10001,10001,10001,10101,10010,01101",
R: "11110,10001,10001,11110,10100,10010,10001",
S: "01111,10000,10000,01110,00001,00001,11110",
T: "11111,00100,00100,00100,00100,00100,00100",
U: "10001,10001,10001,10001,10001,10001,01110",
V: "10001,10001,10001,10001,10001,01010,00100",
W: "10001,10001,10001,10101,10101,11011,10001",
X: "10001,10001,01010,00100,01010,10001,10001",
Y: "10001,10001,01010,00100,00100,00100,00100",
Z: "11111,00001,00010,00100,01000,10000,11111",
0: "01110,10001,10011,10101,11001,10001,01110",
1: "00100,01100,00100,00100,00100,00100,01110",
2: "01110,10001,00001,00010,00100,01000,11111",
3: "11111,00010,00100,00010,00001,10001,01110",
4: "00010,00110,01010,10010,11111,00010,00010",
5: "11111,10000,11110,00001,00001,10001,01110",
6: "00110,01000,10000,11110,10001,10001,01110",
7: "11111,00001,00010,00100,01000,01000,01000",
8: "01110,10001,10001,01110,10001,10001,01110",
9: "01110,10001,10001,01111,00001,00010,01100",
};
const glyph = (ch) => (FONT[ch] || FONT.O).split(",").map((row) => row.split("").map(Number));
// --------------------------------------------------------------------------- //
// 顏色
// --------------------------------------------------------------------------- //
/** h 0360, s 01, l 01 → [r,g,b] 0255 */
export function hslToRgb(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = (((h % 360) + 360) % 360) / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
const [r1, g1, b1] =
hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x]
: hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
const m = l - c / 2;
return [r1 + m, g1 + m, b1 + m].map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255));
}
const hex = ([r, g, b]) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
/** 相對亮度(sRGB → 線性),用來決定字要黑還是白。 */
function luminance([r, g, b]) {
const lin = [r, g, b].map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
// --------------------------------------------------------------------------- //
// 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖
// --------------------------------------------------------------------------- //
export function iconSpec(slug, { code = null, identity = null } = {}) {
const ident = identity || pl.identityFields(slug);
const theCode = code || pl.loadConfig(slug).code || slug;
const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`;
const h = crypto.createHash("sha256").update(seedText).digest();
const hue = (h[0] * 360) / 256;
const hue2 = (hue + 40 + (h[1] % 80)) % 360;
const sat = 0.52 + (h[2] % 30) / 100; // 0.520.81
const light = 0.36 + (h[3] % 18) / 100; // 0.360.53
const c1 = hslToRgb(hue, sat, light);
const c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
// 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)
const pattern = [];
for (let y = 0; y < 5; y += 1) {
const row = [];
for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1);
pattern.push([...row, row[1], row[0]]);
}
const letters = String(theCode).replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 2) || "P";
const onDark = luminance(c1) < 0.32 || luminance(c2) < 0.32;
const ink = onDark ? [255, 255, 255] : [16, 18, 24];
return {
persona: slug,
code: theCode,
name: ident.Name || slug,
emoji: ident.Emoji || "",
letters,
c1,
c2,
ink,
pattern,
seed: h.subarray(0, 8).toString("hex"),
};
}
// --------------------------------------------------------------------------- //
// 幾何:SVG 與柵格器共用同一組座標(單位為 0–1,最後乘上 size)
// --------------------------------------------------------------------------- //
const GEO = {
radius: 0.22, // 圓角半徑
patternInset: 0.12,
patternCell: 0.152,
dotRadius: 0.038,
glyphCell: 1 / 16,
};
/** 回傳圖案中所有圓點(單位座標)。 */
function dots(spec) {
const out = [];
for (let y = 0; y < 5; y += 1) {
for (let x = 0; x < 5; x += 1) {
if (!spec.pattern[y][x]) continue;
out.push({
cx: GEO.patternInset + GEO.patternCell * (x + 0.5),
cy: GEO.patternInset + GEO.patternCell * (y + 0.5),
r: GEO.dotRadius,
});
}
}
return out;
}
/** 回傳字母的所有方塊(單位座標)。 */
function glyphRects(spec) {
const cell = GEO.glyphCell;
const chars = [...spec.letters];
const width = chars.length * 5 * cell + (chars.length - 1) * cell;
const left = 0.5 - width / 2;
const top = 0.5 - (7 * cell) / 2;
const out = [];
chars.forEach((ch, i) => {
const g = glyph(ch);
const ox = left + i * 6 * cell;
for (let y = 0; y < 7; y += 1) {
for (let x = 0; x < 5; x += 1) {
if (g[y][x]) out.push({ x: ox + x * cell, y: top + y * cell, w: cell, h: cell });
}
}
});
return out;
}
// --------------------------------------------------------------------------- //
// SVG
// --------------------------------------------------------------------------- //
export function renderSvg(spec, size = DEFAULT_SIZE) {
const S = size;
const u = (v) => Math.round(v * S * 1000) / 1000;
const lines = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}" role="img"`,
` aria-label="人格 ${spec.code}${spec.name ? `${spec.name}` : ""}">`,
` <title>${spec.code}${spec.name ? ` ${spec.name}` : ""}</title>`,
" <defs>",
` <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">`,
` <stop offset="0" stop-color="${hex(spec.c1)}"/>`,
` <stop offset="1" stop-color="${hex(spec.c2)}"/>`,
" </linearGradient>",
" </defs>",
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
` <g fill="${hex(spec.ink)}" opacity="0.14">`,
...dots(spec).map((d) => ` <circle cx="${u(d.cx)}" cy="${u(d.cy)}" r="${u(d.r)}"/>`),
" </g>",
` <g fill="${hex(spec.ink)}">`,
...glyphRects(spec).map((r) => ` <rect x="${u(r.x)}" y="${u(r.y)}" width="${u(r.w)}" height="${u(r.h)}"/>`),
" </g>",
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
` fill="none" stroke="${hex(spec.ink)}" stroke-opacity="0.18" stroke-width="${u(0.012)}"/>`,
"</svg>",
"",
];
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// PNG:自己柵格化 + 自己編碼(zlib 是內建的,不需要任何影像函式庫)
// --------------------------------------------------------------------------- //
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
/** RGBA buffer → PNG8-bit RGBA、無交錯、filter 0)。 */
export function encodePng(rgba, width, height) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y += 1) {
raw[y * (stride + 1)] = 0; // filter type: None
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", ihdr),
pngChunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
/** 內縮 inset 之後的圓角矩形內外判定(用來畫出與 SVG 描邊相同的環帶)。 */
function insideInset(x, y, inset) {
const span = 1 - 2 * inset;
return insideRounded((x - inset) / span, (y - inset) / span, (GEO.radius - inset) / span);
}
/** 圓角矩形的內外判定(單位座標)。 */
function insideRounded(x, y, r) {
if (x < 0 || y < 0 || x > 1 || y > 1) return false;
const cx = Math.min(Math.max(x, r), 1 - r);
const cy = Math.min(Math.max(y, r), 1 - r);
const dx = x - cx;
const dy = y - cy;
return dx * dx + dy * dy <= r * r;
}
/**
* 畫出與 SVG 完全相同的圖案。
* 用 3× 超取樣再做盒式縮減當作反鋸齒——沒有第三方繪圖庫,這是最省事又夠好的做法。
*/
export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
const SS = Math.max(1, Math.min(4, supersample));
const big = size * SS;
const acc = Buffer.alloc(big * big * 4);
const circles = dots(spec);
const rects = glyphRects(spec);
const [i1, i2, i3] = spec.ink;
for (let py = 0; py < big; py += 1) {
const v = (py + 0.5) / big;
for (let px = 0; px < big; px += 1) {
const u = (px + 0.5) / big;
const o = (py * big + px) * 4;
if (!insideRounded(u, v, GEO.radius)) continue; // 圓角外=透明
// 底:對角線性漸層(與 SVG 的 x1,y1=0,0 → x2,y2=1,1 相同)
const t = Math.min(1, Math.max(0, (u + v) / 2));
let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t;
let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t;
let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t;
// 紋:圓點(14% 不透明度)
for (const d of circles) {
const dx = u - d.cx;
const dy = v - d.cy;
if (dx * dx + dy * dy <= d.r * d.r) {
r += (i1 - r) * 0.14;
g += (i2 - g) * 0.14;
b += (i3 - b) * 0.14;
break;
}
}
// 邊框:與 SVG 的 stroke 完全相同的環帶
//SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.0060.018
if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
r += (i1 - r) * 0.18;
g += (i2 - g) * 0.18;
b += (i3 - b) * 0.18;
}
// 字:實心方塊
for (const rc of rects) {
if (u >= rc.x && u < rc.x + rc.w && v >= rc.y && v < rc.y + rc.h) {
r = i1;
g = i2;
b = i3;
break;
}
}
acc[o] = Math.round(r);
acc[o + 1] = Math.round(g);
acc[o + 2] = Math.round(b);
acc[o + 3] = 255;
}
}
if (SS === 1) return encodePng(acc, size, size);
// 盒式縮減(連 alpha 一起平均,圓角邊緣才會平滑)
const out = Buffer.alloc(size * size * 4);
const n = SS * SS;
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let sy = 0; sy < SS; sy += 1) {
const row = (y * SS + sy) * big;
for (let sx = 0; sx < SS; sx += 1) {
const o = (row + x * SS + sx) * 4;
const av = acc[o + 3];
r += acc[o] * av;
g += acc[o + 1] * av;
b += acc[o + 2] * av;
a += av;
}
}
const o = (y * size + x) * 4;
out[o] = a ? Math.round(r / a) : 0;
out[o + 1] = a ? Math.round(g / a) : 0;
out[o + 2] = a ? Math.round(b / a) : 0;
out[o + 3] = Math.round(a / n);
}
}
return encodePng(out, size, size);
}
// --------------------------------------------------------------------------- //
// 產生並寫檔
// --------------------------------------------------------------------------- //
export function generateIcon(slug, { size = DEFAULT_SIZE, code = null } = {}) {
const spec = iconSpec(slug, { code });
const svg = renderSvg(spec, size);
const png = renderPng(spec, size);
pl.writeText(iconSvgPath(slug), svg);
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
fs.writeFileSync(iconPngPath(slug), png);
return {
spec,
svg: iconSvgPath(slug),
png: iconPngPath(slug),
size,
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
};
}
export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug));